Repository: JunkFood02/Seal Branch: main Commit: d9c741a07a25 Files: 365 Total size: 2.9 MB Directory structure: gitextract_n0w66_ho/ ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ └── workflows/ │ ├── Issue-Handler.yaml │ ├── android.yml │ ├── android_ci.yml │ ├── close-stale-issues.yml │ └── sponsor.yml ├── .gitignore ├── .idea/ │ ├── AndroidProjectSystem.xml │ ├── appInsightsSettings.xml │ ├── codeStyles/ │ │ ├── Project.xml │ │ └── codeStyleConfig.xml │ ├── compiler.xml │ ├── deploymentTargetSelector.xml │ ├── gradle.xml │ ├── inspectionProfiles/ │ │ └── Project_Default.xml │ ├── kotlinc.xml │ ├── ktfmt.xml │ ├── migrations.xml │ ├── misc.xml │ ├── other.xml │ ├── runConfigurations.xml │ ├── studiobot.xml │ └── vcs.xml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── app/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── proguard-rules.pro │ ├── schemas/ │ │ └── com.junkfood.seal.database.AppDatabase/ │ │ ├── 1.json │ │ ├── 2.json │ │ ├── 3.json │ │ ├── 4.json │ │ └── 5.json │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── com/ │ │ └── junkfood/ │ │ └── seal/ │ │ └── ExampleInstrumentedTest.kt │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── junkfood/ │ │ │ └── seal/ │ │ │ ├── App.kt │ │ │ ├── CrashReportActivity.kt │ │ │ ├── DownloadService.kt │ │ │ ├── Downloader.kt │ │ │ ├── MainActivity.kt │ │ │ ├── NotificationActionReceiver.kt │ │ │ ├── QuickDownloadActivity.kt │ │ │ ├── database/ │ │ │ │ ├── AppDatabase.kt │ │ │ │ ├── VideoInfoDao.kt │ │ │ │ ├── backup/ │ │ │ │ │ ├── Backup.kt │ │ │ │ │ └── BackupUtil.kt │ │ │ │ └── objects/ │ │ │ │ ├── CommandTemplate.kt │ │ │ │ ├── CookieProfile.kt │ │ │ │ ├── DownloadedVideoInfo.kt │ │ │ │ └── OptionShortcut.kt │ │ │ ├── download/ │ │ │ │ ├── DownloaderV2.kt │ │ │ │ ├── Task.kt │ │ │ │ └── TaskFactory.kt │ │ │ ├── ui/ │ │ │ │ ├── common/ │ │ │ │ │ ├── AnimatedComposable.kt │ │ │ │ │ ├── AsyncImageImpl.kt │ │ │ │ │ ├── CompositionLocals.kt │ │ │ │ │ ├── Ext.kt │ │ │ │ │ ├── HapticFeedback.kt │ │ │ │ │ ├── Route.kt │ │ │ │ │ └── motion/ │ │ │ │ │ ├── AnimationSpecs.kt │ │ │ │ │ ├── MaterialSharedAxis.kt │ │ │ │ │ └── MotionConstants.kt │ │ │ │ ├── component/ │ │ │ │ │ ├── ActionSheetItems.kt │ │ │ │ │ ├── Buttons.kt │ │ │ │ │ ├── Chips.kt │ │ │ │ │ ├── CommonComponents.kt │ │ │ │ │ ├── DialogItems.kt │ │ │ │ │ ├── Dialogs.kt │ │ │ │ │ ├── DownloadQueueItem.kt │ │ │ │ │ ├── FormatItem.kt │ │ │ │ │ ├── IconButtons.kt │ │ │ │ │ ├── ModalBottomSheetM2.kt │ │ │ │ │ ├── ModalBottomSheetM3.kt │ │ │ │ │ ├── PreferenceItems.kt │ │ │ │ │ ├── SearchBar.kt │ │ │ │ │ ├── SegementedButton.kt │ │ │ │ │ ├── SelectionGroup.kt │ │ │ │ │ ├── SettingItem.kt │ │ │ │ │ ├── SponsorItem.kt │ │ │ │ │ ├── TextField.kt │ │ │ │ │ ├── VideoCard.kt │ │ │ │ │ └── VideoListItem.kt │ │ │ │ ├── page/ │ │ │ │ │ ├── AppEntry.kt │ │ │ │ │ ├── AppUpdater.kt │ │ │ │ │ ├── NavigationDrawer.kt │ │ │ │ │ ├── UpdateDialog.kt │ │ │ │ │ ├── WelcomeDialog.kt │ │ │ │ │ ├── YtdlpUpdater.kt │ │ │ │ │ ├── command/ │ │ │ │ │ │ ├── TaskListPage.kt │ │ │ │ │ │ └── TaskLogPage.kt │ │ │ │ │ ├── download/ │ │ │ │ │ │ ├── DownloadPage.kt │ │ │ │ │ │ ├── DownloadSettingsDialog.kt │ │ │ │ │ │ ├── HomePageViewModel.kt │ │ │ │ │ │ ├── MeteredNetworkDialog.kt │ │ │ │ │ │ ├── NotificationPermissionDialog.kt │ │ │ │ │ │ ├── PlaylistSelectionDialog.kt │ │ │ │ │ │ └── VideoSectionSlider.kt │ │ │ │ │ ├── downloadv2/ │ │ │ │ │ │ ├── ActionSheet.kt │ │ │ │ │ │ ├── DownloadPageV2.kt │ │ │ │ │ │ ├── TopBarNestedScrollConnection.kt │ │ │ │ │ │ ├── VideoCardV2.kt │ │ │ │ │ │ └── configure/ │ │ │ │ │ │ ├── DownloadDialogV2.kt │ │ │ │ │ │ ├── DownloadDialogViewModel.kt │ │ │ │ │ │ ├── FormatPage.kt │ │ │ │ │ │ ├── InputUrlDialog.kt │ │ │ │ │ │ └── PlaylistSelectionPage.kt │ │ │ │ │ ├── settings/ │ │ │ │ │ │ ├── BasePreferencePage.kt │ │ │ │ │ │ ├── SettingsPage.kt │ │ │ │ │ │ ├── about/ │ │ │ │ │ │ │ ├── AboutPage.kt │ │ │ │ │ │ │ ├── CreditsPage.kt │ │ │ │ │ │ │ ├── SponsorPage.kt │ │ │ │ │ │ │ └── UpdatePage.kt │ │ │ │ │ │ ├── appearance/ │ │ │ │ │ │ │ ├── AppearancePreferences.kt │ │ │ │ │ │ │ ├── DarkThemePreferences.kt │ │ │ │ │ │ │ └── LanguagesPage.kt │ │ │ │ │ │ ├── command/ │ │ │ │ │ │ │ ├── CommandTemplateDialog.kt │ │ │ │ │ │ │ ├── TemplateEditPage.kt │ │ │ │ │ │ │ └── TemplateListPage.kt │ │ │ │ │ │ ├── directory/ │ │ │ │ │ │ │ ├── DirectoryPreferenceDialog.kt │ │ │ │ │ │ │ └── DownloadDirectoryPreferences.kt │ │ │ │ │ │ ├── format/ │ │ │ │ │ │ │ ├── DownloadFormatPreferences.kt │ │ │ │ │ │ │ ├── FormatSettingDialogs.kt │ │ │ │ │ │ │ └── SubtitlePreference.kt │ │ │ │ │ │ ├── general/ │ │ │ │ │ │ │ ├── AdvancedSettingDialogs.kt │ │ │ │ │ │ │ ├── GeneralDownloadPreferences.kt │ │ │ │ │ │ │ └── YtdlpUpdateDialog.kt │ │ │ │ │ │ ├── interaction/ │ │ │ │ │ │ │ ├── InteractionPreferencePage.kt │ │ │ │ │ │ │ └── InterfaceCustomizationDialogs.kt │ │ │ │ │ │ ├── network/ │ │ │ │ │ │ │ ├── CookieProfilesPage.kt │ │ │ │ │ │ │ ├── CookiesViewModel.kt │ │ │ │ │ │ │ ├── NetworkPreferences.kt │ │ │ │ │ │ │ ├── NetworkSettingDialogs.kt │ │ │ │ │ │ │ └── WebViewPage.kt │ │ │ │ │ │ └── troubleshooting/ │ │ │ │ │ │ └── TroubleshootingPage.kt │ │ │ │ │ └── videolist/ │ │ │ │ │ ├── ExportImportDialog.kt │ │ │ │ │ ├── RemoveItemDialog.kt │ │ │ │ │ ├── VideoDetailDrawer.kt │ │ │ │ │ ├── VideoListPage.kt │ │ │ │ │ └── VideoListViewModel.kt │ │ │ │ ├── svg/ │ │ │ │ │ ├── VectorPreviews.kt │ │ │ │ │ ├── __DrawableVectors.kt │ │ │ │ │ └── drawablevectors/ │ │ │ │ │ ├── Coder.kt │ │ │ │ │ ├── Download.kt │ │ │ │ │ ├── VideoFiles.kt │ │ │ │ │ └── VideoSteaming.kt │ │ │ │ └── theme/ │ │ │ │ ├── ColorScheme.kt │ │ │ │ ├── Shape.kt │ │ │ │ ├── Theme.kt │ │ │ │ └── Type.kt │ │ │ └── util/ │ │ │ ├── DatabaseUtil.kt │ │ │ ├── DateTimeUtil.kt │ │ │ ├── DownloadUtil.kt │ │ │ ├── FileUtil.kt │ │ │ ├── LanguageSettings.kt │ │ │ ├── NotificationUtil.kt │ │ │ ├── PreferenceUtil.kt │ │ │ ├── SponsorData.kt │ │ │ ├── SponsorUtil.kt │ │ │ ├── TextUtil.kt │ │ │ ├── UpdateUtil.kt │ │ │ └── VideoInfo.kt │ │ └── res/ │ │ ├── drawable/ │ │ │ ├── ic_launcher_foreground.xml │ │ │ ├── ic_launcher_monochrome.xml │ │ │ ├── icons8_matrix.xml │ │ │ ├── icons8_telegram_app.xml │ │ │ ├── outline_cancel_24.xml │ │ │ ├── outline_content_copy_24.xml │ │ │ └── seal.xml │ │ ├── drawable-anydpi-v24/ │ │ │ └── ic_stat_seal.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── resources.properties │ │ ├── values/ │ │ │ ├── ic_launcher_background.xml │ │ │ ├── strings.xml │ │ │ └── themes.xml │ │ ├── values-ar/ │ │ │ └── strings.xml │ │ ├── values-ar-rSA/ │ │ │ └── strings.xml │ │ ├── values-az/ │ │ │ └── strings.xml │ │ ├── values-be/ │ │ │ └── strings.xml │ │ ├── values-bn/ │ │ │ └── strings.xml │ │ ├── values-ca/ │ │ │ └── strings.xml │ │ ├── values-ckb/ │ │ │ └── strings.xml │ │ ├── values-cs/ │ │ │ └── strings.xml │ │ ├── values-da/ │ │ │ └── strings.xml │ │ ├── values-de/ │ │ │ └── strings.xml │ │ ├── values-el/ │ │ │ └── strings.xml │ │ ├── values-es/ │ │ │ └── strings.xml │ │ ├── values-eu/ │ │ │ └── strings.xml │ │ ├── values-fa/ │ │ │ └── strings.xml │ │ ├── values-fil/ │ │ │ └── strings.xml │ │ ├── values-fr/ │ │ │ └── strings.xml │ │ ├── values-gl/ │ │ │ └── strings.xml │ │ ├── values-hi/ │ │ │ └── strings.xml │ │ ├── values-hr/ │ │ │ └── strings.xml │ │ ├── values-hu/ │ │ │ └── strings.xml │ │ ├── values-in/ │ │ │ └── strings.xml │ │ ├── values-it/ │ │ │ └── strings.xml │ │ ├── values-iw/ │ │ │ └── strings.xml │ │ ├── values-ja/ │ │ │ └── strings.xml │ │ ├── values-ji/ │ │ │ └── strings.xml │ │ ├── values-kab/ │ │ │ └── strings.xml │ │ ├── values-km/ │ │ │ └── strings.xml │ │ ├── values-kmr/ │ │ │ └── strings.xml │ │ ├── values-kn/ │ │ │ └── strings.xml │ │ ├── values-ko/ │ │ │ └── strings.xml │ │ ├── values-lt/ │ │ │ └── strings.xml │ │ ├── values-lv/ │ │ │ └── strings.xml │ │ ├── values-ml/ │ │ │ └── strings.xml │ │ ├── values-mn/ │ │ │ └── strings.xml │ │ ├── values-mr/ │ │ │ └── strings.xml │ │ ├── values-ms/ │ │ │ └── strings.xml │ │ ├── values-nb/ │ │ │ └── strings.xml │ │ ├── values-nl/ │ │ │ └── strings.xml │ │ ├── values-nn/ │ │ │ └── strings.xml │ │ ├── values-or/ │ │ │ └── strings.xml │ │ ├── values-pa/ │ │ │ └── strings.xml │ │ ├── values-pl/ │ │ │ └── strings.xml │ │ ├── values-pt/ │ │ │ └── strings.xml │ │ ├── values-pt-rBR/ │ │ │ └── strings.xml │ │ ├── values-pt-rPT/ │ │ │ └── strings.xml │ │ ├── values-ro/ │ │ │ └── strings.xml │ │ ├── values-ru/ │ │ │ └── strings.xml │ │ ├── values-si/ │ │ │ └── strings.xml │ │ ├── values-sk/ │ │ │ └── strings.xml │ │ ├── values-sl/ │ │ │ └── strings.xml │ │ ├── values-sr/ │ │ │ └── strings.xml │ │ ├── values-sv/ │ │ │ └── strings.xml │ │ ├── values-ta/ │ │ │ └── strings.xml │ │ ├── values-th/ │ │ │ └── strings.xml │ │ ├── values-tr/ │ │ │ └── strings.xml │ │ ├── values-uk/ │ │ │ └── strings.xml │ │ ├── values-ur/ │ │ │ └── strings.xml │ │ ├── values-uz/ │ │ │ └── strings.xml │ │ ├── values-vi/ │ │ │ └── strings.xml │ │ ├── values-zh-rCN/ │ │ │ └── strings.xml │ │ ├── values-zh-rTW/ │ │ │ └── strings.xml │ │ └── xml/ │ │ └── provider_paths.xml │ └── test/ │ └── java/ │ └── com/ │ └── junkfood/ │ └── seal/ │ └── ExampleUnitTest.kt ├── build.gradle.kts ├── buildSrc/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ └── main/ │ └── kotlin/ │ └── Version.kt ├── color/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ └── java/ │ ├── com/ │ │ └── kyant/ │ │ └── monet/ │ │ ├── ColorSpec.kt │ │ ├── Monet.kt │ │ ├── PaletteStyle.kt │ │ └── TonalPalettes.kt │ └── io/ │ └── material/ │ ├── hct/ │ │ ├── Cam16.kt │ │ ├── Hct.kt │ │ ├── HctSolver.kt │ │ └── ViewingConditions.kt │ └── utils/ │ ├── ColorUtils.kt │ ├── MathUtils.kt │ └── StringUtils.kt ├── fastlane/ │ └── metadata/ │ └── android/ │ ├── ar-SA/ │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── bn/ │ │ ├── short_description.txt │ │ └── title.txt │ ├── de-DE/ │ │ ├── changelogs/ │ │ │ ├── 10320.txt │ │ │ ├── 10330.txt │ │ │ ├── 10340.txt │ │ │ └── 10350.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── en-US/ │ │ ├── changelogs/ │ │ │ ├── 10704.txt │ │ │ ├── 10714.txt │ │ │ ├── 10724.txt │ │ │ ├── 10734.txt │ │ │ ├── 10804.txt │ │ │ ├── 10814.txt │ │ │ └── 10824.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── es/ │ │ ├── changelogs/ │ │ │ ├── 10320.txt │ │ │ ├── 10330.txt │ │ │ └── 10340.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── fr-FR/ │ │ ├── changelogs/ │ │ │ └── 10350.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── hi/ │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── hr/ │ │ ├── changelogs/ │ │ │ ├── 10330.txt │ │ │ └── 10340.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── id/ │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── it/ │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── ja/ │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── ml/ │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── nb-NO/ │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── nl-NL/ │ │ ├── changelogs/ │ │ │ └── 10350.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── pt-BR/ │ │ ├── short_description.txt │ │ └── title.txt │ ├── ru/ │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── th/ │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── uk/ │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── vi/ │ │ ├── changelogs/ │ │ │ └── 10320.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── zh-CN/ │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ └── zh-TW/ │ ├── changelogs/ │ │ └── 10330.txt │ ├── full_description.txt │ ├── short_description.txt │ └── title.txt ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── settings.gradle.kts └── translations/ ├── README-ar.md ├── README-az.md ├── README-bn.md ├── README-fa.md ├── README-hi.md ├── README-id.md ├── README-it.md ├── README-ja.md ├── README-pt.md ├── README-ru.md ├── README-sr.md ├── README-th.md ├── README-ua.md ├── README-zh_Hans.md └── README-zh_Hant.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: JunkFood02 patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug Report description: Create a report to help us improve labels: [ bug, new issue ] body: - type: checkboxes id: checklist attributes: label: Checklist description: | Carefully read and work through this check list in order to prevent the most common mistakes and misuse of Seal/Yt-dlp: options: - label: I'm reporting a bug unrelated to a specific site. required: false - label: I've verified that I'm running the [**latest version**](https://github.com/yt-dlp/yt-dlp/releases/latest) of yt-dlp. required: true - label: I've verified that I'm running the latest [**stable version**](https://github.com/JunkFood02/Seal/releases/latest/) of Seal or any later [**preview versions**](https://github.com/JunkFood02/Seal/releases). required: true - label: I've read the [**Contributing guidelines**](https://github.com/JunkFood02/Seal/blob/main/CONTRIBUTING.md) and [**Code Of Conduct.**](https://github.com/JunkFood02/Seal/blob/main/CODE_OF_CONDUCT.md) required: true - label: I've checked that the site i'm trying to download from is in the [**Supported Sites**](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) list from yt-dlp required: true - label: I understand that the issue will be (ignored/closed) if I intentionally remove or skip any mandatory field. required: true - type: textarea attributes: label: Describe the bug description: placeholder: | A clear and concise description of what the bug is. validations: required: false - type: textarea attributes: label: To Reproduce placeholder: | Steps to reproduce the behavior: 1.Go to '...' 2.Click on '....' 3.Scroll down to '....' 4.See error validations: required: false - type: textarea attributes: label: Error reports placeholder: | Click on the displayed error report to copy it. validations: required: true - type: textarea attributes: label: Screenshots & Screen Records placeholder: | Screenshots & Screen Records can amp up bug reports. validations: required: false - type: textarea attributes: label: Additional context description: placeholder: | Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ # disable blank issue creation blank_issues_enabled: false ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: Feature Request description: Suggest a new feature for the app labels: [ enhancement, new issue ] body: - type: checkboxes id: checklist attributes: label: Checklist description: | Even if you're not sure about the answer, feel free to leave it blank and provide us with more information about this request. options: - label: This feature I'm requesting is already implemented in yt-dlp. required: false - label: This feature is merely a UI/UX update. required: false - label: This feature is suitable for primary users with little knowledge about yt-dlp. required: false - label: This feature is available for most websites, not only the video platform I use. required: false - label: This feature is suitable for a large variety of videos. required: false - label: This feature is not going to conflict with many of the existing options. required: false - type: textarea id: description_1 attributes: label: Is your feature request related to a problem? Please describe. description: placeholder: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] validations: required: false - type: textarea id: description_2 attributes: label: Describe the solution you'd like description: placeholder: A clear and concise description of what you want to happen. validations: required: false - type: textarea id: description_3 attributes: label: Video link description: placeholder: Please provide us with a link to the video for which this feature might be beneficial. validations: required: false - type: textarea id: description_4 attributes: label: Additional context description: placeholder: Add any other context or screenshots about the feature request here. validations: required: false render: shell ================================================ FILE: .github/workflows/Issue-Handler.yaml ================================================ # Name of the GitHub Action name: Check and Close Issues # Trigger the action on issue events, specifically when an issue is opened on: issues: types: [opened] # Job definitions jobs: handle-issues: # Run this job only for issues if: github.event_name == 'issues' # Specify the runner environment runs-on: ubuntu-latest steps: # Step 1: Check out the repository code - name: Check out code uses: actions/checkout@v4 # Step 2: Set up Node.js environment (version 16) - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 16 # Step 3: Custom script to check and close issues - name: Check and close issues id: close-issues uses: actions/github-script@v7 with: github-token: ${{secrets.GITHUB_TOKEN}} script: | // Define keywords and labels for issue filtering const keywordsToCheck = ['instagram', 'facebook', 'twitter', 'HTTP Error 403', 'not a bot']; const requiredLabel = 'new issue'; const referenceIssueNumber = 1399; const actionClosedLabel = 'action-closed'; // Unique label to track action-closed issues // Function to process each issue async function processIssue(issue) { const issueBody = issue.body.toLowerCase(); const issueLabels = issue.labels.map(label => label.name); const wasClosedByAction = issueLabels.includes(actionClosedLabel); // Determine if the issue should be closed const shouldCloseIssue = !wasClosedByAction && keywordsToCheck.some(keyword => issueBody.includes(keyword)) && issueLabels.includes(requiredLabel); // Close the issue if it meets the criteria if (shouldCloseIssue) { await github.rest.issues.update({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, state: 'closed' }); // Add labels and comment to the closed issue await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, labels: ['duplicate', actionClosedLabel] }); await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, name: requiredLabel }); await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, body: `This issue has been closed and labeled as duplicate. Please see issue #${referenceIssueNumber} for more details. If you believe this is not the case, you can reopen this issue.` }); } } // Process newly opened issues if (context.payload.action === 'opened') { await processIssue(context.payload.issue); } ================================================ FILE: .github/workflows/android.yml ================================================ name: Build Release APK on: workflow_dispatch: jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: set up JDK 21 uses: actions/setup-java@v4 with: java-version: '21' distribution: 'temurin' cache: 'gradle' - name: Setup Android SDK uses: android-actions/setup-android@v3 - uses: gradle/actions/setup-gradle@v3 - run: gradle assembleRelease - name: Sign app APK id: sign_app uses: ilharp/sign-android-release@nightly with: releaseDir: app/build/outputs/apk/release signingKey: ${{ secrets.SIGNING_KEY }} keyAlias: ${{ secrets.ALIAS }} keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} - name: Upload Artifact uses: actions/upload-artifact@v4 with: name: signed-apks path: app/build/outputs/apk/release/*-arm64-v8a-release-signed.apk if-no-files-found: error retention-days: 20 ================================================ FILE: .github/workflows/android_ci.yml ================================================ name: Android CI on: pull_request: branches: [ "main" ] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: set up JDK 21 uses: actions/setup-java@v3 with: java-version: '21' distribution: 'temurin' cache: gradle - name: Setup Android SDK uses: android-actions/setup-android@v3 - uses: gradle/actions/setup-gradle@v3 - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Build with Gradle run: ./gradlew buildGenericRelease ================================================ FILE: .github/workflows/close-stale-issues.yml ================================================ name: 'Close stale issues and PRs' on: schedule: - cron: '0 0 1 * *' jobs: stale: runs-on: ubuntu-latest steps: - uses: actions/stale@v9 with: stale-issue-message: 'This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 30 days.' days-before-stale: 90 days-before-close: 30 ================================================ FILE: .github/workflows/sponsor.yml ================================================ name: Generate Sponsors README on: workflow_dispatch: schedule: - cron: 30 15 25 * * jobs: deploy: runs-on: ubuntu-latest if: ${{ github.repository == 'JunkFood02/Seal' }} steps: - name: Checkout 🛎️ uses: actions/checkout@v2 - name: Generate Sponsors 💖 uses: JamesIves/github-sponsors-readme-action@v1 with: token: ${{ secrets.PAT }} file: 'README.md' minimum: 500 - name: Deploy to GitHub Pages 🚀 uses: JamesIves/github-pages-deploy-action@v4 with: branch: main token: ${{ secrets.PAT }} folder: '.' commit-message: 'docs(readme): update sponsor info' ================================================ FILE: .gitignore ================================================ *.iml .gradle /local.properties /.idea/caches /.idea/libraries /.idea/modules.xml /.idea/workspace.xml /.idea/navEditor.xml /.idea/assetWizardSettings.xml /.idea/deploymentTargetDropDown.xml /.idea/shelf .DS_Store /build /captures .externalNativeBuild .cxx local.properties /keystore.properties .kotlin ================================================ FILE: .idea/AndroidProjectSystem.xml ================================================ ================================================ FILE: .idea/appInsightsSettings.xml ================================================ ================================================ FILE: .idea/codeStyles/Project.xml ================================================ ================================================ FILE: .idea/codeStyles/codeStyleConfig.xml ================================================ ================================================ FILE: .idea/compiler.xml ================================================ ================================================ FILE: .idea/deploymentTargetSelector.xml ================================================ ================================================ FILE: .idea/gradle.xml ================================================ ================================================ FILE: .idea/inspectionProfiles/Project_Default.xml ================================================ ================================================ FILE: .idea/kotlinc.xml ================================================ ================================================ FILE: .idea/ktfmt.xml ================================================ ================================================ FILE: .idea/migrations.xml ================================================ ================================================ FILE: .idea/misc.xml ================================================ ================================================ FILE: .idea/other.xml ================================================ ================================================ FILE: .idea/runConfigurations.xml ================================================ ================================================ FILE: .idea/studiobot.xml ================================================ ================================================ FILE: .idea/vcs.xml ================================================ ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes (starting from v1.7.3) to stable releases will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [v2.0.0][2.0.0] - unreleased ### Notable changes from v1.13 - Concurrent downloading - Download queue - User interface overhaul - Large screen support - Resume failed/canceled download - Backup & restore unfinished tasks in the download queue - Select from formats/playlists in Quick Download - Predictive back animation support for Android 14+ - Bump up minimum API level to 24 (Android 7.0) ## [v1.13.0][1.13.0] - 2024-08-18 ### Fixed - Fix the issue where exported command templates could not be imported in v1.12.x - Fix an unexpected behavior where multiple formats would be selected ### Change - Update `youtubedl-android` to v0.16.1 - Update translations ## [v1.12.1][1.12.1] - 2024-04-17 ### Added * Add auto update interval for yt-dlp * Cookies page now shows the current count of cookies stored in the database ### Fixed * Intercept non-HTTP(s) URLs opened in WebView * Videos are remuxed to mkv even when download subtitle is disabled * Use MD2 ModalBottomSheetLayout in devices on API < 30 * Block downloads when updating yt-dlp ### Known issues * TextFields(IME) fallback to plain character mode when showing a ModalBottomSheet * yt-dlp might be broken if you tried to download something while it was updating (`bad local file header`). To fix it, you just need to update yt-dlp again ## [v1.12.0][1.12.0] - 2024-04-05 ### Added * Search from download history * Search from subtitles in format selection page * Export download history to file/clipboard * Import download history from file/clipboard * Re-download unavailable videos * Download auto-translated subtitles * Remember subtitle selection for next downloads * Remux videos into mkv container for better compatibility * Configuration for not using the download type in the last download * Improve UI/UX for download error handling * Add splash screen * Haptic feedback BZZZTT!!1! ### Changed * Long pressing on an item in download history now selects it * Use nightly builds for yt-dlp by default * Migrate `Slider` & `ProgressIndicator` to the new visual styles in MD3 * Use default display name from system for locales * Metadata of videos is also embedded in the files now * A few UI changes that I forgot ### Fixed * Fix a permission issue when using Seal in a different user profile or private space * Fix an issue where the text cannot be copied in the menu of the download history * Display approximate file size for formats when there's no exact value available * Fix an issue causes app to crash when the selected template is not available * Custom command now ignore empty URLs, which means you can insert URLs along with arguments in command templates * Fix an issue where some formats may be unavailable when downloading playlists ### Known issues * TextFields(IME) fallback to plain character mode when showing a ModalBottomSheet * ModalBottomSheet handles insets incorrectly on devices below API 30 ## [v1.11.3][1.11.3] - 2024-01-22 ### Added * Merge multiple audio streams into a single file * Allow downloading with cellular network temporarily ### Fixed * App creates duplicated command templates on initialization * Cannot make video clip in FormatPage ## [v1.11.2][1.11.2] - 2024-01-06 ### Added * Keep subtitles files after embedding into videos * Force all connections via ipv4 * Prefer vp9.2 if av1 hardware decoding unavailable * Add system locale settings for Android 13+ ### Fixed * User agent gets enabled when refreshing cookies * Restrict filenames not working in custom commands ### Changed * Transition animation should look more smooth now ## [v1.11.1][1.11.1] - 2023-12-16 ### Added * Add `--restrict-filenames` option in yt-dlp * Add playlist title as an option for subdirectory * Add more thanks to sponsors ### Fixed * Fix some minor UI bugs * Fix an issue causing error when parsing video info ## [v1.11.0][1.11.0] - 2023-11-18 ### Added * Custom output template (`-o` option in yt-dlp) * Export cookies to a text file * Make embed metadata in audio files optional * Add the ability to record download archive, and skip duplicate downloads * Add cancel button to the download page * Add input chips for sponsorblock categories * Add subtitle selection dialog in format page, make auto-translated subtitles available in subtitle selection * Add more thanks to sponsors ### Changed * Move the directory for storing temporary files to external storage (`Seal/tmp`) * Change the default output template to `%(title)s.%(ext)s` * Temporary directory now are enabled by default for downloads in general mode * Move actions in format page to dropdown menu * Download subtitles are now available when downloading audio files * `android:enableOnBackInvokedCallback` is changed to `false` due to compatibility issues ### Fixed * Fix an issue causes sharing videos to fail on certain devices * Fix an issue causes uploader marked as null, make uploader_id as a fallback to uploader * Fix an issue when a user performs multiple clicks causing duplicate navigating behaviors ### Removed * Custom prefix for output template has been removed, please migrate to custom output template ## [v1.10.0][1.10.0] - 2023-08-30 ### Added **Subtitles** * Convert subtitles to another format * Select subtitle language in format selection **Format selection** * Display icons(video/audio) on `FormatItem`s * Split video by chapters * Select subtitle to download by language names/codes **Custom commands** * Create custom command tasks in the Running Tasks page * Configure download directory separately for custom command tasks * Select multiple command templates to export & remove **Cookies** * Add `CookiesQuickSettingsDialog` for refreshing & configuring cookies in configuration menu * Add user agent header when downloading with cookies enabled **Other New Features & UI Improvements** * Show `PlainToolTip` when long-press on `PlaylistItem` * Add monochrome theme * Add proxy configuration for network connections * Add translations in Swedish and Portuguese ### Fixed * App crashes when being opened in the system share sheet * Video not shown in YouTube playlist results * Cookies cannot be disabled after clearing cookies * Hide video only formats when save as audio enabled * Parsing error with decimal value in width/height * Audio codec preference not works as expected * Could not fetch video info when `originalUrl` is null ### Changed **Notable Changes** * Upgrade target API level to 34 (Android 14) * Preferred video format changed to two options: Legacy and Quality * UI improvements to the configuration dialog **Other Changes** * Update `ColorScheme`s and components to reflect the new MD3 color roles * Update youtubedl-android version, added pycryptodomex to the library * Move Video formats to the bottom of the `FormatPage` * Notifications now are enabled by default * Minor UI improvements & changes ## [v1.9.2][1.9.2] - 2023-04-27 ### Fixed * Fix a bug causing Incognito mode not working in v1.9.1 * Fix misplaced quality tags in `AudioQuickSettingsDialog` * Fix mismatched formats when using Save as audio & Download playlist ## [v1.9.1][1.9.1] - 2023-04-11 ### Added * Add Sponsor page: You can now support this app by sponsoring on GitHub! ### Fixed * Fix a bug causing warnings not shown in logs of completed custom command tasks * Fix a bug causing videos not scanned into media library when private mode is enabled ### Changed * Move the directory for temporary files to `cacheDir` ## [v1.9.0][1.9.0] - 2023-03-12 ### Added * Add Preview channel for auto-updating * Add an option to update to Nightly builds of yt-dlp * Add a dialog for F-Droid builds in auto-update settings * Add a switch for auto-updating yt-dlp * Add the ability to share files in `VideoDetailDrawer` * Add a badge to the icon to indicate the count of running processes * Add a switch for disabling the temporary directory * Add format & quality preference for audio * Add custom format sorter * Add the ability to clip video and audio in `FormatSelectionPage` (experimental) * Add the ability to edit video titles in `FormatSelectionPage` before downloading * Add the ability to share the thumbnail url in `FormatSelectionPage` * Implement a new method to extract cookies from the `WebView` database ### Changed - Change the operation of open link to long pressing the link button in `VideoDetailDrawer` - Change the thread number range of multi-threaded download to 1-24 - Change the status bar icon to filled icon - Change the quick settings for media format in the configuration dialog ### Fixed - Fix a bug causing high-quality audio not downloaded with YT Premium cookies & YT Music URLs - UI bug in `ShortcutChip` with long template - Fix a bug causing empty subtitle language breaks downloads - Fix an issue causing specific languages not visible in system settings on Android 13+ - Fix a UI bug in the format selection page - Fix a bug causing app to crash when toasting in Android 5.0 - Fix a UI bug causing LTR texts to display incorrectly in RTL locale environment - Add legacy app icon for API 21~25 ### Known issues - Cookies may not work as expected in some devices, please try to re-generate cookies after this occurs. File an issue on GitHub with your device info when experience errors. ## [v1.8.2][1.8.2] - 2023-02-10 ### Fixed - Trimmed ASCII characters filename - Unexpected error when downloading multiple video to SD card with quick download - Error when cropping vertical thumbnails as artwork - ID conflicts when importing custom templates ### Changed - Add `horizontalScroll` to `LogPage` - Revert the URL intent filters ## [v1.8.1][1.8.1] - 2023-02-01 ### Fixed - App crashes when downloading in private mode - Unexpected ImeActions in TextFields - Disable SD card download when the directory is not set - Localized strings for file size texts ## [v1.8.0][1.8.0] - 2023-01-29 ### Added - Download to SD card - Quick download in parallel - Task dashboard & log page for custom commands - Custom shortcuts for command templates - Subtitle preferences - Apply `--embed-chapters` for video downloads by default - New color schemes for UI theming ### Changed - New transition animation between destinations - Change `minSdkVersion` to 21 (Android 5.0) - Accessibility improvements to components - Revert playlist items limit in v1.7.3 - Scan the download directory to the system media library after running commands - Change the LongClick operations of `FormatItem` to share the stream URLs ## [v1.7.3][1.7.3] - 2023-01-10 ### Fixed - `Webview` captures Cookies from wrong domains - Notifications of custom commands remain unfinished status - App crashes when fails to parse video info for format selection - App crashes when parsing channel info for playlist download ### Added - Tips about streams merging in `FormatSelectionPage` ### Changed - Playlist results are limited to 200 videos [1.7.3]: https://github.com/JunkFood02/Seal/releases/tag/v1.7.3 [1.8.0]: https://github.com/JunkFood02/Seal/releases/tag/v1.8.0 [1.8.1]: https://github.com/JunkFood02/Seal/releases/tag/v1.8.1 [1.8.2]: https://github.com/JunkFood02/Seal/releases/tag/v1.8.2 [1.9.0]: https://github.com/JunkFood02/Seal/releases/tag/v1.9.0 [1.9.1]: https://github.com/JunkFood02/Seal/releases/tag/v1.9.1 [1.9.2]: https://github.com/JunkFood02/Seal/releases/tag/v1.9.2 [1.10.0]: https://github.com/JunkFood02/Seal/releases/tag/v1.10.0 [1.11.0]: https://github.com/JunkFood02/Seal/releases/tag/v1.11.0 [1.11.1]: https://github.com/JunkFood02/Seal/releases/tag/v1.11.1 [1.11.2]: https://github.com/JunkFood02/Seal/releases/tag/v1.11.2 [1.11.3]: https://github.com/JunkFood02/Seal/releases/tag/v1.11.3 [1.12.0]: https://github.com/JunkFood02/Seal/releases/tag/v1.12.0 [1.12.1]: https://github.com/JunkFood02/Seal/releases/tag/v1.12.1 [1.13.0]: https://github.com/JunkFood02/Seal/releases/tag/v1.13.0 ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at junkfood02@proton.me. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing Before reading, you may know what [yt-dlp](https://github.com/yt-dlp/yt-dlp) is and what it does. In short, it's a CLI (Command Line Interface) program written in python, which lets you download videos from [1000+ websites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md). For bug reports and feature requests, please search in issues first (including the closed ones). If there're no duplicates, feel free to [submit an issue](https://github.com/JunkFood02/Seal/issues/new) with an issue template. **We'll probably ignore and close your issue if it's not using the existing templates or doesn't contain sufficient description.** For questions or any other ideas to improve, you can join our official [Telegram group](https://t.me/seal_app_group) or [Matrix space](https://matrix.to/#/#seal-space:matrix.org). ## Disclaimer This is a toy project I use to learn Android development. Please do not have any expectations or assumptions about the quality of the code. ## Bug Report When submitting a bug report, please make sure your issue contains **enough** information for reproducing the problem, including the options or the custom command being used, the link to the video, and other fields in the issue template. ## Feature Request Seal is and will remain a simple GUI for yt-dlp, providing most of the functionality of yt-dlp as is, without modifications. Thus, **we'll not take requests for features that yt-dlp does not support.** The app has two download modes: - General mode: Save as audio, download playlist, and many other options that can be used individually or combined for normal download tasks. Once the download is complete, Seal will scan the files into the system media library, and store them in the download history. - Custom command mode: For advanced usage of yt-dlp, a user can create and store multiple command templates in the app, then select and use one of them directly to execute the yt-dlp command like in a terminal. In this mode, all of the GUI options and features in the general mode will be disabled. Since most of the functions can be implemented in custom command mode, the "feature request" would be treated as adding a shortcut to the general mode. However, not all feature requests will be accepted and implemented in the app. [Why not add an option for that?](https://neugierig.org/software/blog/2018/07/options.html) ## Pull Request If you wish to contribute to the project by submitting code directly, please first leave a comment under the relevant issue or file a new issue, describe the changes you are about to make. To avoid multiple pull requests resolving the same issue, let others know you are working on it by saying so in a comment, or ask the issue to be assigned to yourself. ## New contributors Scan through our [existing issues](https://github.com/JunkFood02/Seal/issues) to find one that interests you. The [👋 good first issue](https://github.com/JunkFood02/Seal/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) is a good place to start exploring issues that are up-for-grab for newcomers. (Do not hesitate to ask for more details or clarifying questions on the issue!) ## Building From Source Fork this project, import and compile it with the latest version of [Android Studio Canary](https://developer.android.com/studio/preview). ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================
# Seal ### Video/Audio Downloader for Android English   |    简体中文   |    繁體中文   |    العربية   |    Portuguese   |    Українська   |    ภาษาไทย   |    فارسی   |    Italiano   |    Azərbaycanca   |    Русский   |    Српски   |    日本語   |    Indonesia   |    हिंदी   |    বাংলা [![F-Droid](https://img.shields.io/f-droid/v/com.junkfood.seal?color=b4eb12&label=F-Droid&logo=fdroid&logoColor=1f78d2)](https://f-droid.org/en/packages/com.junkfood.seal) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/JunkFood02/Seal?color=black&label=Stable&logo=github)](https://github.com/JunkFood02/Seal/releases/latest/) [![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/JunkFood02/Seal?include_prereleases&label=Preview&logo=Github)](https://github.com/JunkFood02/Seal/releases/) [![Keep a Changelog](https://img.shields.io/badge/Changelog-lightgray?style=flat&color=gray&logo=keep-a-changelog)](https://github.com/JunkFood02/Seal/blob/main/CHANGELOG.md) [![GitHub all releases](https://img.shields.io/github/downloads/JunkFood02/Seal/total?label=Downloads&logo=github)](https://github.com/JunkFood02/Seal/releases/) [![GitHub Repo stars](https://img.shields.io/github/stars/JunkFood02/Seal?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIC05NjAgOTYwIDk2MCIgd2lkdGg9IjI0IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxwYXRoIGQ9Im0zNTQtMjQ3IDEyNi03NiAxMjYgNzctMzMtMTQ0IDExMS05Ni0xNDYtMTMtNTgtMTM2LTU4IDEzNS0xNDYgMTMgMTExIDk3LTMzIDE0M1pNMjMzLTgwbDY1LTI4MUw4MC01NTBsMjg4LTI1IDExMi0yNjUgMTEyIDI2NSAyODggMjUtMjE4IDE4OSA2NSAyODEtMjQ3LTE0OUwyMzMtODBabTI0Ny0zNTBaIiBzdHlsZT0iZmlsbDogcmdiKDI0NSwgMjI3LCA2Nik7Ii8%2BCjwvc3ZnPg%3D%3D&color=%23f8e444)](https://github.com/JunkFood02/Seal/stargazers) [![Supported-Sites](https://img.shields.io/badge/Sites-9cf?style=flat&logo=data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyBoZWlnaHQ9IjI0cHgiIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0cHgiIGZpbGw9IiNGRkZGRkYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTAgMGgyNHYyNEgwVjB6IiBmaWxsPSJub25lIi8+CiAgPHBhdGggZD0iTTExLjk5IDJDNi40NyAyIDIgNi40OCAyIDEyczQuNDcgMTAgOS45OSAxMEMxNy41MiAyMiAyMiAxNy41MiAyMiAxMlMxNy41MiAyIDExLjk5IDJ6bTYuOTMgNmgtMi45NWMtLjMyLTEuMjUtLjc4LTIuNDUtMS4zOC0zLjU2IDEuODQuNjMgMy4zNyAxLjkxIDQuMzMgMy41NnpNMTIgNC4wNGMuODMgMS4yIDEuNDggMi41MyAxLjkxIDMuOTZoLTMuODJjLjQzLTEuNDMgMS4wOC0yLjc2IDEuOTEtMy45NnpNNC4yNiAxNEM0LjEgMTMuMzYgNCAxMi42OSA0IDEycy4xLTEuMzYuMjYtMmgzLjM4Yy0uMDguNjYtLjE0IDEuMzItLjE0IDJzLjA2IDEuMzQuMTQgMkg0LjI2em0uODIgMmgyLjk1Yy4zMiAxLjI1Ljc4IDIuNDUgMS4zOCAzLjU2LTEuODQtLjYzLTMuMzctMS45LTQuMzMtMy41NnptMi45NS04SDUuMDhjLjk2LTEuNjYgMi40OS0yLjkzIDQuMzMtMy41NkM4LjgxIDUuNTUgOC4zNSA2Ljc1IDguMDMgOHpNMTIgMTkuOTZjLS44My0xLjItMS40OC0yLjUzLTEuOTEtMy45NmgzLjgyYy0uNDMgMS40My0xLjA4IDIuNzYtMS45MSAzLjk2ek0xNC4zNCAxNEg5LjY2Yy0uMDktLjY2LS4xNi0xLjMyLS4xNi0ycy4wNy0xLjM1LjE2LTJoNC42OGMuMDkuNjUuMTYgMS4zMi4xNiAycy0uMDcgMS4zNC0uMTYgMnptLjI1IDUuNTZjLjYtMS4xMSAxLjA2LTIuMzEgMS4zOC0zLjU2aDIuOTVjLS45NiAxLjY1LTIuNDkgMi45My00LjMzIDMuNTZ6TTE2LjM2IDE0Yy4wOC0uNjYuMTQtMS4zMi4xNC0ycy0uMDYtMS4zNC0uMTQtMmgzLjM4Yy4xNi42NC4yNiAxLjMxLjI2IDJzLS4xIDEuMzYtLjI2IDJoLTMuMzh6IiBzdHlsZT0iZmlsbDogcmdiKDE2MiwgMTk4LCAyMzQpOyIvPgo8L3N2Zz4=&label=Supported)](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) [![Telegram Channel](https://img.shields.io/badge/Telegram-Seal-blue?style=flat&logo=telegram)](https://t.me/seal_app) [![Matrix](https://img.shields.io/matrix/seal-space%3Amatrix.org?server_fqdn=matrix.org&style=flat&logo=element&label=Matrix&color=%230DBD8B) ](https://matrix.to/#/#seal-space:matrix.org)
## 📱 Screenshots

## 📖 Features - Download videos and audio files from video platforms supported by [yt-dlp](https://github.com/yt-dlp/yt-dlp) (formerly youtube-dl). - Embed metadata and video thumbnail into extracted audio files supported by [mutagen](https://github.com/quodlibet/mutagen). - Download all videos in the playlist with one click. - Use embedded [aria2c](https://github.com/aria2/aria2) as external downloader for all your downloads. - Embed subtitles into the downloaded videos. - Execute custom yt-dlp commands with templates. - Manage in-app downloads and custom command templates. - Easy to use and user-friendly. - [Material Design 3](https://m3.material.io/) style UI, with dynamic color theme. - MAD: UI and logic written with pure Kotlin. Single activity, no fragments, only composable destinations. ## ⬇️ Download For most devices, it is recommended to install the **arm64-v8a** version of the apks - Download the latest stable version from [GitHub releases](https://github.com/JunkFood02/Seal/releases/latest) - Install the [pre-release](https://github.com/JunkFood02/Seal/releases/) versions to help us test out new features & changes - Stable releases are also available on [F-Droid](https://f-droid.org/packages/com.junkfood.seal/) ## 💬 Contact Join our [Telegram Channel](https://t.me/seal_app) or [Matrix Space](https://matrix.to/#/#seal-space:matrix.org) for discussion, announcements, and releases! ## 💖 Sponsors

User avatar: Cook I.T!User avatar: User avatar: User avatar: Jeff Rosen

Seal will be always free and open source for everyone. If you like it, please consider [sponsoring me](https://github.com/sponsors/JunkFood02)! ## 🤝 Contributing Contributions are welcome! You can help translate Seal on [Hosted Weblate](https://hosted.weblate.org/projects/seal/). [![Translate status](https://hosted.weblate.org/widgets/seal/-/strings/multi-auto.svg)](https://hosted.weblate.org/engage/seal/) >[!Note] > >For submitting bug reports, feature requests, questions, or any other ideas to improve, please read [CONTRIBUTING.md](https://github.com/JunkFood02/Seal/blob/main/CONTRIBUTING.md) for instructions and guidelines first. ## ⭐️ Star History [![Star History Chart](https://api.star-history.com/svg?repos=JunkFood02/Seal&type=Timeline)](https://star-history.com/#JunkFood02/Seal&Timeline) ## 🧱 Credits Seal is a simple GUI of [yt-dlp](https://github.com/yt-dlp/yt-dlp), based on [youtubedl-android](https://github.com/yausername/youtubedl-android) Some of the UI designs and codes are borrowed from [Read You](https://github.com/Ashinch/ReadYou) and [Music You](https://github.com/Kyant0/MusicYou) [dvd](https://github.com/yausername/dvd) [Material color utilities](https://github.com/material-foundation/material-color-utilities) [Monet](https://github.com/Kyant0/Monet) ## 📃 License [![GitHub](https://img.shields.io/github/license/JunkFood02/Seal?style=for-the-badge)](https://github.com/JunkFood02/Seal/blob/main/LICENSE) >[!Warning] > >Except for the source code licensed under the GPLv3 license, >all other parties are prohibited from using Seal's name as a downloader app, >and the same is true for Seal's derivatives. >Derivatives include but are not limited to forks and unofficial builds. ================================================ FILE: app/.gitignore ================================================ /build ================================================ FILE: app/build.gradle.kts ================================================ @file:Suppress("UnstableApiUsage") import com.android.build.api.variant.FilterConfiguration import java.io.FileInputStream import java.util.Properties plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) alias(libs.plugins.compose.compiler) alias(libs.plugins.room) alias(libs.plugins.ktfmt.gradle) } val keystorePropertiesFile: File = rootProject.file("keystore.properties") val splitApks = !project.hasProperty("noSplits") val abiFilterList = (properties["ABI_FILTERS"] as String).split(';') val abiCodes = mapOf("armeabi-v7a" to 1, "arm64-v8a" to 2, "x86" to 3, "x86_64" to 4) val baseVersionName = currentVersion.name val currentVersionCode = currentVersion.code.toInt() android { compileSdk = 35 if (keystorePropertiesFile.exists()) { val keystoreProperties = Properties() keystoreProperties.load(FileInputStream(keystorePropertiesFile)) signingConfigs { create("githubPublish") { keyAlias = keystoreProperties["keyAlias"].toString() keyPassword = keystoreProperties["keyPassword"].toString() storeFile = file(keystoreProperties["storeFile"]!!) storePassword = keystoreProperties["storePassword"].toString() } } } buildFeatures { buildConfig = true } defaultConfig { applicationId = "com.junkfood.seal" minSdk = 24 targetSdk = 35 versionCode = 200_000_150 check(versionCode == currentVersionCode) versionName = baseVersionName testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true } if (splitApks) { splits { abi { isEnable = true reset() include("arm64-v8a", "armeabi-v7a", "x86", "x86_64") isUniversalApk = true } } } else { ndk { abiFilters.addAll(abiFilterList) } } } room { schemaDirectory("$projectDir/schemas") } ksp { arg("room.incremental", "true") } androidComponents { onVariants { variant -> variant.outputs.forEach { output -> val name = if (splitApks) { output.filters .find { it.filterType == FilterConfiguration.FilterType.ABI } ?.identifier } else { abiFilterList.firstOrNull() } val baseAbiCode = abiCodes[name] if (baseAbiCode != null) { output.versionCode.set(baseAbiCode + (output.versionCode.get() ?: 0)) } } } } buildTypes { release { isMinifyEnabled = true isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) if (keystorePropertiesFile.exists()) { signingConfig = signingConfigs.getByName("githubPublish") } } debug { if (keystorePropertiesFile.exists()) { signingConfig = signingConfigs.getByName("githubPublish") } applicationIdSuffix = ".debug" versionNameSuffix = "-debug" resValue("string", "app_name", "Seal Debug") } } flavorDimensions += "publishChannel" productFlavors { create("generic") { dimension = "publishChannel" isDefault = true } create("githubPreview") { dimension = "publishChannel" applicationIdSuffix = ".preview" resValue("string", "app_name", "Seal Preview") } create("fdroid") { dimension = "publishChannel" versionName = "$baseVersionName-(F-Droid)" } } lint { disable.addAll(listOf("MissingTranslation", "ExtraTranslation", "MissingQuantity")) } applicationVariants.all { outputs.all { (this as com.android.build.gradle.internal.api.BaseVariantOutputImpl).outputFileName = "Seal-${defaultConfig.versionName}-${name}.apk" } } kotlinOptions { freeCompilerArgs = freeCompilerArgs + "-opt-in=kotlin.RequiresOptIn" } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } jniLibs.useLegacyPackaging = true } androidResources { generateLocaleConfig = true } namespace = "com.junkfood.seal" } ktfmt { kotlinLangStyle() } kotlin { jvmToolchain(21) } dependencies { implementation(project(":color")) implementation(libs.bundles.core) implementation(libs.androidx.lifecycle.runtimeCompose) implementation(platform(libs.androidx.compose.bom)) implementation(libs.bundles.androidxCompose) implementation(libs.bundles.accompanist) implementation(libs.coil.kt.compose) implementation(libs.kotlinx.serialization.json) implementation(libs.koin.android) implementation(libs.koin.compose) implementation(libs.room.runtime) implementation(libs.room.ktx) ksp(libs.room.compiler) implementation(libs.okhttp) implementation(libs.bundles.youtubedlAndroid) implementation(libs.mmkv) testImplementation(libs.junit4) androidTestImplementation(libs.androidx.test.ext) androidTestImplementation(libs.androidx.test.espresso.core) implementation(libs.androidx.compose.ui.tooling) } ================================================ FILE: app/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile #noinspection ShrinkerUnresolvedReference -dontobfuscate -keep class com.yausername.** { *; } -keep class org.apache.commons.compress.archivers.zip.** { *; } # Keep `Companion` object fields of serializable classes. # This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. -if @kotlinx.serialization.Serializable class ** -keepclassmembers class <1> { static <1>$Companion Companion; } # Keep `serializer()` on companion objects (both default and named) of serializable classes. -if @kotlinx.serialization.Serializable class ** { static **$* *; } -keepclassmembers class <2>$<3> { kotlinx.serialization.KSerializer serializer(...); } # Keep `INSTANCE.serializer()` of serializable objects. -if @kotlinx.serialization.Serializable class ** { public static ** INSTANCE; } -keepclassmembers class <1> { public static <1> INSTANCE; kotlinx.serialization.KSerializer serializer(...); } # @Serializable and @Polymorphic are used at runtime for polymorphic serialization. -keepattributes RuntimeVisibleAnnotations,AnnotationDefault # Serializer for classes with named companion objects are retrieved using `getDeclaredClasses`. # If you have any, uncomment and replace classes with those containing named companion objects. #-keepattributes InnerClasses # Needed for `getDeclaredClasses`. #-if @kotlinx.serialization.Serializable class #com.example.myapplication.HasNamedCompanion, # <-- List serializable classes with named companions. #com.example.myapplication.HasNamedCompanion2 #{ # static **$* *; #} #-keepnames class <1>$$serializer { # -keepnames suffices; class is kept when serializer() is kept. # static <1>$$serializer INSTANCE; #} ================================================ FILE: app/schemas/com.junkfood.seal.database.AppDatabase/1.json ================================================ { "formatVersion": 1, "database": { "version": 1, "identityHash": "988509a71f29b1a28b60e346980acccb", "entities": [ { "tableName": "DownloadedVideoInfo", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `videoTitle` TEXT NOT NULL, `videoAuthor` TEXT NOT NULL, `videoUrl` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `videoPath` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "videoTitle", "columnName": "videoTitle", "affinity": "TEXT", "notNull": true }, { "fieldPath": "videoAuthor", "columnName": "videoAuthor", "affinity": "TEXT", "notNull": true }, { "fieldPath": "videoUrl", "columnName": "videoUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "videoPath", "columnName": "videoPath", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [], "foreignKeys": [] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '988509a71f29b1a28b60e346980acccb')" ] } } ================================================ FILE: app/schemas/com.junkfood.seal.database.AppDatabase/2.json ================================================ { "formatVersion": 1, "database": { "version": 2, "identityHash": "4af4e9805a6d4977cdf27c8cb419c965", "entities": [ { "tableName": "DownloadedVideoInfo", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `videoTitle` TEXT NOT NULL, `videoAuthor` TEXT NOT NULL, `videoUrl` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `videoPath` TEXT NOT NULL, `extractor` TEXT NOT NULL DEFAULT 'Unknown')", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "videoTitle", "columnName": "videoTitle", "affinity": "TEXT", "notNull": true }, { "fieldPath": "videoAuthor", "columnName": "videoAuthor", "affinity": "TEXT", "notNull": true }, { "fieldPath": "videoUrl", "columnName": "videoUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "videoPath", "columnName": "videoPath", "affinity": "TEXT", "notNull": true }, { "fieldPath": "extractor", "columnName": "extractor", "affinity": "TEXT", "notNull": true, "defaultValue": "'Unknown'" } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [], "foreignKeys": [] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4af4e9805a6d4977cdf27c8cb419c965')" ] } } ================================================ FILE: app/schemas/com.junkfood.seal.database.AppDatabase/3.json ================================================ { "formatVersion": 1, "database": { "version": 3, "identityHash": "63b1cd29253fd3dd9060188d793fa8d3", "entities": [ { "tableName": "DownloadedVideoInfo", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `videoTitle` TEXT NOT NULL, `videoAuthor` TEXT NOT NULL, `videoUrl` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `videoPath` TEXT NOT NULL, `extractor` TEXT NOT NULL DEFAULT 'Unknown')", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "videoTitle", "columnName": "videoTitle", "affinity": "TEXT", "notNull": true }, { "fieldPath": "videoAuthor", "columnName": "videoAuthor", "affinity": "TEXT", "notNull": true }, { "fieldPath": "videoUrl", "columnName": "videoUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "videoPath", "columnName": "videoPath", "affinity": "TEXT", "notNull": true }, { "fieldPath": "extractor", "columnName": "extractor", "affinity": "TEXT", "notNull": true, "defaultValue": "'Unknown'" } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [], "foreignKeys": [] }, { "tableName": "CommandTemplate", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `template` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "template", "columnName": "template", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [], "foreignKeys": [] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '63b1cd29253fd3dd9060188d793fa8d3')" ] } } ================================================ FILE: app/schemas/com.junkfood.seal.database.AppDatabase/4.json ================================================ { "formatVersion": 1, "database": { "version": 4, "identityHash": "d049bb757be0d1c233c7ec34bfde51dc", "entities": [ { "tableName": "DownloadedVideoInfo", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `videoTitle` TEXT NOT NULL, `videoAuthor` TEXT NOT NULL, `videoUrl` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `videoPath` TEXT NOT NULL, `extractor` TEXT NOT NULL DEFAULT 'Unknown')", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "videoTitle", "columnName": "videoTitle", "affinity": "TEXT", "notNull": true }, { "fieldPath": "videoAuthor", "columnName": "videoAuthor", "affinity": "TEXT", "notNull": true }, { "fieldPath": "videoUrl", "columnName": "videoUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "videoPath", "columnName": "videoPath", "affinity": "TEXT", "notNull": true }, { "fieldPath": "extractor", "columnName": "extractor", "affinity": "TEXT", "notNull": true, "defaultValue": "'Unknown'" } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "CommandTemplate", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `template` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "template", "columnName": "template", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "CookieProfile", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `content` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd049bb757be0d1c233c7ec34bfde51dc')" ] } } ================================================ FILE: app/schemas/com.junkfood.seal.database.AppDatabase/5.json ================================================ { "formatVersion": 1, "database": { "version": 5, "identityHash": "5eab3a1c93713521f1197fa2e2903231", "entities": [ { "tableName": "DownloadedVideoInfo", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `videoTitle` TEXT NOT NULL, `videoAuthor` TEXT NOT NULL, `videoUrl` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `videoPath` TEXT NOT NULL, `extractor` TEXT NOT NULL DEFAULT 'Unknown')", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "videoTitle", "columnName": "videoTitle", "affinity": "TEXT", "notNull": true }, { "fieldPath": "videoAuthor", "columnName": "videoAuthor", "affinity": "TEXT", "notNull": true }, { "fieldPath": "videoUrl", "columnName": "videoUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": true }, { "fieldPath": "videoPath", "columnName": "videoPath", "affinity": "TEXT", "notNull": true }, { "fieldPath": "extractor", "columnName": "extractor", "affinity": "TEXT", "notNull": true, "defaultValue": "'Unknown'" } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "CommandTemplate", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `template` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "template", "columnName": "template", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "CookieProfile", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `content` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": true }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "OptionShortcut", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `option` TEXT NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "option", "columnName": "option", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5eab3a1c93713521f1197fa2e2903231')" ] } } ================================================ FILE: app/src/androidTest/java/com/junkfood/seal/ExampleInstrumentedTest.kt ================================================ package com.junkfood.seal import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import org.junit.Assert.* import org.junit.Test import org.junit.runner.RunWith /** * Instrumented test, which will execute on an Android device. * * See [testing documentation](http://d.android.com/tools/testing). */ @RunWith(AndroidJUnit4::class) class ExampleInstrumentedTest { @Test fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("com.junkfood.Seal", appContext.packageName) } } ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/java/com/junkfood/seal/App.kt ================================================ package com.junkfood.seal import android.annotation.SuppressLint import android.app.Application import android.content.ClipboardManager import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.net.ConnectivityManager import android.net.Uri import android.os.Build import android.os.IBinder import androidx.core.content.getSystemService import com.google.android.material.color.DynamicColors import com.junkfood.seal.download.DownloaderV2 import com.junkfood.seal.download.DownloaderV2Impl import com.junkfood.seal.ui.page.download.HomePageViewModel import com.junkfood.seal.ui.page.downloadv2.configure.DownloadDialogViewModel import com.junkfood.seal.ui.page.settings.directory.Directory import com.junkfood.seal.ui.page.settings.network.CookiesViewModel import com.junkfood.seal.ui.page.videolist.VideoListViewModel import com.junkfood.seal.util.AUDIO_DIRECTORY import com.junkfood.seal.util.COMMAND_DIRECTORY import com.junkfood.seal.util.DownloadUtil import com.junkfood.seal.util.FileUtil import com.junkfood.seal.util.FileUtil.createEmptyFile import com.junkfood.seal.util.FileUtil.getCookiesFile import com.junkfood.seal.util.FileUtil.getExternalDownloadDirectory import com.junkfood.seal.util.FileUtil.getExternalPrivateDownloadDirectory import com.junkfood.seal.util.NotificationUtil import com.junkfood.seal.util.PreferenceUtil import com.junkfood.seal.util.PreferenceUtil.getString import com.junkfood.seal.util.PreferenceUtil.updateString import com.junkfood.seal.util.SDCARD_URI import com.junkfood.seal.util.UpdateUtil import com.junkfood.seal.util.VIDEO_DIRECTORY import com.junkfood.seal.util.YT_DLP_VERSION import com.tencent.mmkv.MMKV import com.yausername.aria2c.Aria2c import com.yausername.ffmpeg.FFmpeg import com.yausername.youtubedl_android.YoutubeDL import java.io.File import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.core.context.startKoin import org.koin.core.module.dsl.viewModel import org.koin.dsl.module class App : Application() { override fun onCreate() { super.onCreate() MMKV.initialize(this) startKoin { androidLogger() androidContext(this@App) modules( module { single { DownloaderV2Impl(androidContext()) } viewModel { DownloadDialogViewModel(downloader = get()) } viewModel { HomePageViewModel() } viewModel { CookiesViewModel() } viewModel { VideoListViewModel() } } ) } context = applicationContext packageInfo = packageManager.run { if (Build.VERSION.SDK_INT >= 33) getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) else getPackageInfo(packageName, 0) } applicationScope = CoroutineScope(SupervisorJob()) DynamicColors.applyToActivitiesIfAvailable(this) clipboard = getSystemService()!! connectivityManager = getSystemService()!! applicationScope.launch((Dispatchers.IO)) { try { YoutubeDL.init(this@App) FFmpeg.init(this@App) Aria2c.init(this@App) DownloadUtil.getCookiesContentFromDatabase().getOrNull()?.let { FileUtil.writeContentToFile(it, getCookiesFile()) } UpdateUtil.deleteOutdatedApk() } catch (th: Throwable) { withContext(Dispatchers.Main) { startCrashReportActivity(th) } } } videoDownloadDir = VIDEO_DIRECTORY.getString(getExternalDownloadDirectory().absolutePath) audioDownloadDir = AUDIO_DIRECTORY.getString(File(videoDownloadDir, "Audio").absolutePath) if (!PreferenceUtil.containsKey(COMMAND_DIRECTORY)) { COMMAND_DIRECTORY.updateString(videoDownloadDir) } if (Build.VERSION.SDK_INT >= 26) NotificationUtil.createNotificationChannel() Thread.setDefaultUncaughtExceptionHandler { _, e -> startCrashReportActivity(e) } } private fun startCrashReportActivity(th: Throwable) { th.printStackTrace() startActivity( Intent(this, CrashReportActivity::class.java) .setAction("$packageName.error_report") .apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK putExtra("error_report", getVersionReport() + "\n" + th.stackTraceToString()) } ) } companion object { lateinit var clipboard: ClipboardManager lateinit var videoDownloadDir: String lateinit var audioDownloadDir: String lateinit var applicationScope: CoroutineScope lateinit var connectivityManager: ConnectivityManager lateinit var packageInfo: PackageInfo var isServiceRunning = false private val connection = object : ServiceConnection { override fun onServiceConnected(className: ComponentName, service: IBinder) { val binder = service as DownloadService.DownloadServiceBinder isServiceRunning = true } override fun onServiceDisconnected(arg0: ComponentName) {} } fun startService() { if (isServiceRunning) return Intent(context.applicationContext, DownloadService::class.java).also { intent -> context.applicationContext.bindService(intent, connection, Context.BIND_AUTO_CREATE) } } fun stopService() { if (!isServiceRunning) return try { isServiceRunning = false context.applicationContext.run { unbindService(connection) } } catch (e: Exception) { e.printStackTrace() } } val privateDownloadDir: String get() = getExternalPrivateDownloadDirectory().run { createEmptyFile(".nomedia") absolutePath } fun updateDownloadDir(uri: Uri, directoryType: Directory) { when (directoryType) { Directory.AUDIO -> { val path = FileUtil.getRealPath(uri) audioDownloadDir = path PreferenceUtil.encodeString(AUDIO_DIRECTORY, path) } Directory.VIDEO -> { val path = FileUtil.getRealPath(uri) videoDownloadDir = path PreferenceUtil.encodeString(VIDEO_DIRECTORY, path) } Directory.CUSTOM_COMMAND -> { val path = FileUtil.getRealPath(uri) } Directory.SDCARD -> { context.contentResolver?.takePersistableUriPermission( uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, ) PreferenceUtil.encodeString(SDCARD_URI, uri.toString()) } } } fun getVersionReport(): String { val versionName = packageInfo.versionName val page = packageInfo val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { packageInfo.longVersionCode } else { packageInfo.versionCode.toLong() } val release = if (Build.VERSION.SDK_INT >= 30) { Build.VERSION.RELEASE_OR_CODENAME } else { Build.VERSION.RELEASE } return StringBuilder() .append("App version: $versionName ($versionCode)\n") .append("Device information: Android $release (API ${Build.VERSION.SDK_INT})\n") .append("Supported ABIs: ${Build.SUPPORTED_ABIS.contentToString()}\n") .append("Yt-dlp version: ${YT_DLP_VERSION.getString()}\n") .toString() } fun isFDroidBuild(): Boolean = BuildConfig.FLAVOR == "fdroid" fun isDebugBuild(): Boolean = BuildConfig.DEBUG @SuppressLint("StaticFieldLeak") lateinit var context: Context } } ================================================ FILE: app/src/main/java/com/junkfood/seal/CrashReportActivity.kt ================================================ package com.junkfood.seal import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.BugReport import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.junkfood.seal.ui.common.LocalDarkTheme import com.junkfood.seal.ui.common.SettingsProvider import com.junkfood.seal.ui.component.FilledButtonWithIcon import com.junkfood.seal.ui.theme.SealTheme class CrashReportActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() val errorMessage: String = intent.getStringExtra("error_report").toString() setContent { SettingsProvider(WindowWidthSizeClass.Compact) { SealTheme( darkTheme = LocalDarkTheme.current.isDarkTheme(), isHighContrastModeEnabled = LocalDarkTheme.current.isHighContrastModeEnabled, ) { val clipboardManager = LocalClipboardManager.current CrashReportPage(errorMessage = errorMessage) { clipboardManager.setText(AnnotatedString(errorMessage)) this.finishAffinity() } } } } } override fun onDestroy() { super.onDestroy() if (isFinishing) finishAffinity() } } @Composable @Preview fun CrashReportPage(errorMessage: String = "ERROR_EXAMPLE", onClick: () -> Unit = {}) { Scaffold( modifier = Modifier.fillMaxSize(), bottomBar = { androidx.compose.material3.HorizontalDivider() FilledButtonWithIcon( modifier = Modifier.fillMaxWidth() .navigationBarsPadding() .padding(horizontal = 16.dp, vertical = 8.dp), onClick = onClick, icon = Icons.Outlined.BugReport, text = stringResource(R.string.copy_and_exit), ) }, ) { Column(modifier = Modifier.padding(it).verticalScroll(rememberScrollState())) { Text( text = stringResource(R.string.unknown_error_title), style = MaterialTheme.typography.displaySmall, modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 60.dp, bottom = 12.dp), ) Text( text = errorMessage, style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(16.dp).fillMaxWidth(), ) } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/DownloadService.kt ================================================ package com.junkfood.seal import android.app.PendingIntent import android.app.Service import android.content.Intent import android.os.Binder import android.os.Build import android.os.IBinder import android.util.Log import com.junkfood.seal.util.NotificationUtil import com.junkfood.seal.util.NotificationUtil.SERVICE_NOTIFICATION_ID private const val TAG = "DownloadService" /** This `Service` does nothing */ class DownloadService : Service() { override fun onBind(intent: Intent): IBinder { val pendingIntent: PendingIntent = Intent(this, MainActivity::class.java).let { notificationIntent -> PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE) } val notification = NotificationUtil.makeServiceNotification(pendingIntent) startForeground(SERVICE_NOTIFICATION_ID, notification) return DownloadServiceBinder() } override fun onUnbind(intent: Intent?): Boolean { Log.d(TAG, "onUnbind: ") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { stopForeground(STOP_FOREGROUND_REMOVE) } else { stopForeground(true) } stopSelf() return super.onUnbind(intent) } inner class DownloadServiceBinder : Binder() { fun getService(): DownloadService = this@DownloadService } } ================================================ FILE: app/src/main/java/com/junkfood/seal/Downloader.kt ================================================ package com.junkfood.seal import android.app.PendingIntent import android.util.Log import androidx.annotation.CheckResult import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateMapOf import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import com.junkfood.seal.App.Companion.applicationScope import com.junkfood.seal.App.Companion.context import com.junkfood.seal.App.Companion.startService import com.junkfood.seal.App.Companion.stopService import com.junkfood.seal.database.objects.CommandTemplate import com.junkfood.seal.util.COMMAND_DIRECTORY import com.junkfood.seal.util.DownloadUtil import com.junkfood.seal.util.FileUtil import com.junkfood.seal.util.NotificationUtil import com.junkfood.seal.util.PlaylistEntry import com.junkfood.seal.util.PlaylistResult import com.junkfood.seal.util.PreferenceUtil.getString import com.junkfood.seal.util.ToastUtil import com.junkfood.seal.util.VideoInfo import com.junkfood.seal.util.toHttpsUrl import com.yausername.youtubedl_android.YoutubeDL import java.util.concurrent.CancellationException import kotlin.math.roundToInt import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** Singleton Downloader for state holder & perform downloads, used by `Activity` & `Service` */ object Downloader { private const val TAG = "Downloader" sealed class State { data class DownloadingPlaylist(val currentItem: Int = 0, val itemCount: Int = 0) : State() data object DownloadingVideo : State() data object FetchingInfo : State() data object Idle : State() data object Updating : State() } sealed class ErrorState(open val url: String = "", open val report: String = "") { data class DownloadError(override val url: String, override val report: String) : ErrorState(url = url, report = report) data class FetchInfoError(override val url: String, override val report: String) : ErrorState(url = url, report = report) data object None : ErrorState() val title: String @Composable get() = when (this) { is DownloadError -> stringResource(id = R.string.download_error_msg) is FetchInfoError -> stringResource(id = R.string.fetch_info_error_msg) None -> "" } } data class CustomCommandTask( val template: CommandTemplate, val url: String, val output: String, val state: State, val currentLine: String, ) { fun toKey() = makeKey(url, template.name) sealed class State { data class Error(val errorReport: String) : State() object Completed : State() object Canceled : State() data class Running(val progress: Float) : State() } override fun hashCode(): Int { return (this.url + this.template.name + this.template.template).hashCode() } override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as CustomCommandTask if (template != other.template) return false if (url != other.url) return false if (output != other.output) return false if (state != other.state) return false if (currentLine != other.currentLine) return false return true } fun onCopyLog(clipboardManager: ClipboardManager) { clipboardManager.setText(AnnotatedString(output)) } fun onRestart() { applicationScope.launch(Dispatchers.IO) { DownloadUtil.executeCommandInBackground(url, template) } } fun onCopyError(clipboardManager: ClipboardManager) { clipboardManager.setText(AnnotatedString(currentLine)) ToastUtil.makeToast(R.string.error_copied) } fun onCancel() { toKey().run { YoutubeDL.destroyProcessById(this) onProcessCanceled(this) } } } data class DownloadTaskItem( val webpageUrl: String = "", val title: String = "", val uploader: String = "", val duration: Int = 0, val fileSizeApprox: Double = .0, val progress: Float = 0f, val progressText: String = "", val thumbnailUrl: String = "", val taskId: String = "", val playlistIndex: Int = 0, ) private var currentJob: Job? = null private var downloadResultTemp: Result> = Result.failure(Exception()) private val mutableDownloaderState: MutableStateFlow = MutableStateFlow(State.Idle) private val mutableTaskState = MutableStateFlow(DownloadTaskItem()) private val mutablePlaylistResult = MutableStateFlow(PlaylistResult()) private val mutableErrorState: MutableStateFlow = MutableStateFlow(ErrorState.None) private val mutableProcessCount = MutableStateFlow(0) private val mutableQuickDownloadCount = MutableStateFlow(0) val mutableTaskList = mutableStateMapOf() val taskState = mutableTaskState.asStateFlow() val downloaderState = mutableDownloaderState.asStateFlow() val playlistResult = mutablePlaylistResult.asStateFlow() val errorState = mutableErrorState.asStateFlow() val processCount = mutableProcessCount.asStateFlow() init { applicationScope.launch { downloaderState .combine(processCount) { state, cnt -> if (cnt > 0) true else when (state) { is State.Idle -> false else -> true } } .combine(mutableQuickDownloadCount) { isRunning, cnt -> if (!isRunning) cnt > 0 else true } .collect { if (it) startService() else stopService() } } } fun isDownloaderAvailable(): Boolean { return downloaderState.value is State.Idle } fun makeKey(url: String, templateName: String): String = "${templateName}_$url" fun onTaskStarted(template: CommandTemplate, url: String) = CustomCommandTask( template = template, url = url, output = "", state = CustomCommandTask.State.Running(0f), currentLine = "", ) .run { mutableTaskList.put(this.toKey(), this) } fun updateTaskOutput(template: CommandTemplate, url: String, line: String, progress: Float) { val key = makeKey(url, template.name) val oldValue = mutableTaskList[key] ?: return val newValue = oldValue.run { copy( output = output + line + "\n", currentLine = line, state = CustomCommandTask.State.Running(progress), ) } mutableTaskList[key] = newValue } fun onTaskEnded(template: CommandTemplate, url: String, response: String? = null) { val key = makeKey(url, template.name) NotificationUtil.finishNotification( notificationId = key.toNotificationId(), title = key, text = context.getString(R.string.status_completed), ) mutableTaskList.run { val oldValue = get(key) ?: return val newValue = oldValue.copy(state = CustomCommandTask.State.Completed).run { response?.let { copy(output = response) } ?: this } this[key] = newValue } FileUtil.scanDownloadDirectoryToMediaLibrary(COMMAND_DIRECTORY.getString()) } fun onProcessEnded() = mutableProcessCount.update { it - 1 } fun onProcessCanceled(taskId: String) = mutableTaskList.run { get(taskId)?.let { this.put(taskId, it.copy(state = CustomCommandTask.State.Canceled)) } } fun onTaskError(errorReport: String, template: CommandTemplate, url: String) = mutableTaskList.run { val key = makeKey(url, template.name) NotificationUtil.notifyError( title = "", notificationId = key.toNotificationId(), report = errorReport, ) val oldValue = mutableTaskList[key] ?: return mutableTaskList[key] = oldValue.copy( state = CustomCommandTask.State.Error(errorReport), currentLine = errorReport, output = oldValue.output + "\n" + errorReport, ) } private fun VideoInfo.toTask(playlistIndex: Int = 0, preferencesHash: Int): DownloadTaskItem = DownloadTaskItem( webpageUrl = webpageUrl.toString(), title = title, uploader = uploader ?: channel ?: uploaderId.toString(), duration = duration?.roundToInt() ?: 0, taskId = id + preferencesHash, thumbnailUrl = thumbnail.toHttpsUrl(), fileSizeApprox = fileSize ?: fileSizeApprox ?: .0, playlistIndex = playlistIndex, ) fun updateState(state: State) = mutableDownloaderState.update { state } fun clearErrorState() { mutableErrorState.update { ErrorState.None } } private fun fetchInfoError(url: String, errorReport: String) { mutableErrorState.update { ErrorState.FetchInfoError(url, errorReport) } } private fun downloadError(url: String, errorReport: String) { mutableErrorState.update { ErrorState.DownloadError(url, errorReport) } } private fun clearProgressState(isFinished: Boolean) { mutableTaskState.update { it.copy(progress = if (isFinished) 100f else 0f, progressText = "") } if (!isFinished) downloadResultTemp = Result.failure(Exception()) } fun updatePlaylistResult(playlistResult: PlaylistResult = PlaylistResult()) = mutablePlaylistResult.update { playlistResult } fun getInfoAndDownload( url: String, preferences: DownloadUtil.DownloadPreferences = DownloadUtil.DownloadPreferences.createFromPreferences(), ) { currentJob = applicationScope.launch(Dispatchers.IO) { updateState(State.FetchingInfo) DownloadUtil.fetchVideoInfoFromUrl(url = url, preferences = preferences) .onFailure { manageDownloadError( th = it, url = url, isFetchingInfo = true, isTaskAborted = true, ) } .onSuccess { info -> downloadResultTemp = downloadVideo(videoInfo = info, preferences = preferences) } } } fun addToDownloadQueue( videoInfo: VideoInfo? = null, url: String = videoInfo?.originalUrl ?: "", preferences: DownloadUtil.DownloadPreferences = DownloadUtil.DownloadPreferences.createFromPreferences(), ) { require(url.isNotEmpty() || videoInfo != null) if (!isDownloaderAvailable()) { ToastUtil.makeToast(R.string.task_added) applicationScope .launch(Dispatchers.Default) { while (!isDownloaderAvailable()) { delay(3000) } } .invokeOnCompletion { videoInfo?.let { downloadVideoWithInfo(info = videoInfo, preferences = preferences) } ?: getInfoAndDownload(url, preferences) } } else { videoInfo?.let { downloadVideoWithInfo(info = videoInfo, preferences = preferences) } ?: getInfoAndDownload(url, preferences) } } fun downloadVideoWithInfo( info: VideoInfo, preferences: DownloadUtil.DownloadPreferences = DownloadUtil.DownloadPreferences.createFromPreferences(), ) { currentJob = applicationScope.launch(Dispatchers.IO) { downloadResultTemp = downloadVideo(videoInfo = info, preferences = preferences) } } /** * This method is used for download a single video and multiple videos from playlist at the same * time. * * @see downloadVideoInPlaylistByIndexList * @see getInfoAndDownload */ @CheckResult private suspend fun downloadVideo( playlistIndex: Int = 0, playlistUrl: String = "", videoInfo: VideoInfo, preferences: DownloadUtil.DownloadPreferences = DownloadUtil.DownloadPreferences.createFromPreferences(), ): Result> { Log.d(TAG, preferences.subtitleLanguage) mutableTaskState.update { videoInfo.toTask(preferencesHash = preferences.hashCode()) } val isDownloadingPlaylist = downloaderState.value is State.DownloadingPlaylist if (!isDownloadingPlaylist) updateState(State.DownloadingVideo) val taskId = videoInfo.id + preferences.hashCode() val notificationId = taskId.toNotificationId() Log.d(TAG, "downloadVideo: id=${videoInfo.id} " + videoInfo.title) Log.d(TAG, "notificationId: $notificationId") NotificationUtil.notifyProgress(notificationId = notificationId, title = videoInfo.title) return DownloadUtil.downloadVideo( videoInfo = videoInfo, playlistUrl = playlistUrl, playlistItem = playlistIndex, downloadPreferences = preferences, taskId = videoInfo.id + preferences.hashCode(), ) { progress, _, line -> Log.d(TAG, line) mutableTaskState.update { it.copy(progress = progress, progressText = line) } NotificationUtil.notifyProgress( notificationId = notificationId, progress = progress.toInt(), text = line, title = videoInfo.title, taskId = taskId, ) } .onFailure { manageDownloadError( th = it, url = videoInfo.originalUrl, title = videoInfo.title, isFetchingInfo = false, notificationId = notificationId, isTaskAborted = !isDownloadingPlaylist, ) } .onSuccess { if (!isDownloadingPlaylist) finishProcessing() val text = context.getString( if (it.isEmpty()) R.string.status_completed else R.string.download_finish_notification ) FileUtil.createIntentForOpeningFile(it.firstOrNull()).run { NotificationUtil.finishNotification( notificationId, title = videoInfo.title, text = text, intent = if (this != null) PendingIntent.getActivity( context, 0, this, PendingIntent.FLAG_IMMUTABLE, ) else null, ) } } } fun downloadVideoInPlaylistByIndexList( url: String, indexList: List, playlistItemList: List = emptyList(), preferences: DownloadUtil.DownloadPreferences = DownloadUtil.DownloadPreferences.createFromPreferences(), ) { val itemCount = indexList.size if (!isDownloaderAvailable()) return mutableDownloaderState.update { State.DownloadingPlaylist() } currentJob = applicationScope.launch(Dispatchers.IO) { for (i in indexList.indices) { mutableDownloaderState.update { if (it is State.DownloadingPlaylist) it.copy(currentItem = i + 1, itemCount = indexList.size) else return@launch } NotificationUtil.updateServiceNotificationForPlaylist( index = i + 1, itemCount = itemCount, ) val playlistIndex = indexList[i] val playlistEntry = playlistItemList.getOrNull(i) Log.d(TAG, playlistEntry?.title.toString()) val title = playlistEntry?.title DownloadUtil.fetchVideoInfoFromUrl( url = url, playlistIndex = playlistIndex, preferences = preferences, ) .onSuccess { if (downloaderState.value !is State.DownloadingPlaylist) return@launch downloadResultTemp = downloadVideo( videoInfo = it, playlistIndex = playlistIndex, playlistUrl = url, preferences = preferences, ) .onFailure { th -> manageDownloadError( th = th, url = it.originalUrl, title = it.title, isFetchingInfo = false, isTaskAborted = false, ) } } .onFailure { th -> manageDownloadError( th = th, url = playlistEntry?.url, title = title, isFetchingInfo = true, isTaskAborted = false, ) } } finishProcessing() } } private fun finishProcessing() { if (downloaderState.value is State.Idle) return mutableTaskState.update { it.copy(progress = 100f, progressText = "") } clearProgressState(isFinished = true) updateState(State.Idle) clearErrorState() } /** * @param isTaskAborted Determines if the download task is aborted due to the given `Exception` */ fun manageDownloadError( th: Throwable, url: String?, title: String? = null, isFetchingInfo: Boolean, isTaskAborted: Boolean = true, notificationId: Int? = null, ) { if (th is YoutubeDL.CanceledException) return th.printStackTrace() val resId = if (isFetchingInfo) R.string.fetch_info_error_msg else R.string.download_error_msg ToastUtil.makeToastSuspend(context.getString(resId)) val notificationTitle = title ?: url if (isFetchingInfo) { fetchInfoError(url = url.toString(), errorReport = th.message.toString()) } else { downloadError(url = url.toString(), errorReport = th.message.toString()) } notificationId?.let { NotificationUtil.finishNotification( notificationId = notificationId, title = notificationTitle, text = context.getString(R.string.download_error_msg), ) } if (isTaskAborted) { updateState(State.Idle) clearProgressState(isFinished = false) } } fun cancelDownload() { ToastUtil.makeToast(context.getString(R.string.task_canceled)) currentJob?.cancel(CancellationException(context.getString(R.string.task_canceled))) updateState(State.Idle) clearProgressState(isFinished = false) taskState.value.taskId.run { YoutubeDL.destroyProcessById(this) NotificationUtil.cancelNotification(this.toNotificationId()) } } fun executeCommandWithUrl(url: String) = applicationScope.launch(Dispatchers.IO) { DownloadUtil.executeCommandInBackground(url) } fun openDownloadResult() { if (taskState.value.progress == 100f) FileUtil.openFileFromResult(downloadResultTemp) } fun onProcessStarted() = mutableProcessCount.update { it + 1 } fun String.toNotificationId(): Int = this.hashCode() } ================================================ FILE: app/src/main/java/com/junkfood/seal/MainActivity.kt ================================================ package com.junkfood.seal import android.content.Intent import android.os.Build import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import com.junkfood.seal.App.Companion.context import com.junkfood.seal.ui.common.LocalDarkTheme import com.junkfood.seal.ui.common.SettingsProvider import com.junkfood.seal.ui.page.AppEntry import com.junkfood.seal.ui.page.downloadv2.configure.DownloadDialogViewModel import com.junkfood.seal.ui.theme.SealTheme import com.junkfood.seal.util.PreferenceUtil import com.junkfood.seal.util.matchUrlFromSharedText import com.junkfood.seal.util.setLanguage import kotlinx.coroutines.runBlocking import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.compose.KoinContext class MainActivity : AppCompatActivity() { private val dialogViewModel: DownloadDialogViewModel by viewModel() @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (Build.VERSION.SDK_INT < 33) { runBlocking { setLanguage(PreferenceUtil.getLocaleFromPreference()) } } enableEdgeToEdge() context = this.baseContext setContent { KoinContext { val windowSizeClass = calculateWindowSizeClass(this) SettingsProvider(windowWidthSizeClass = windowSizeClass.widthSizeClass) { SealTheme( darkTheme = LocalDarkTheme.current.isDarkTheme(), isHighContrastModeEnabled = LocalDarkTheme.current.isHighContrastModeEnabled, ) { AppEntry(dialogViewModel = dialogViewModel) } } } } } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) val url = intent.getSharedURL() if (url != null) { dialogViewModel.postAction(DownloadDialogViewModel.Action.ShowSheet(listOf(url))) } } private fun Intent.getSharedURL(): String? { val intent = this return when (intent.action) { Intent.ACTION_VIEW -> { intent.dataString } Intent.ACTION_SEND -> { intent.getStringExtra(Intent.EXTRA_TEXT)?.let { sharedContent -> intent.removeExtra(Intent.EXTRA_TEXT) matchUrlFromSharedText(sharedContent).also { matchedUrl -> if (sharedUrlCached != matchedUrl) { sharedUrlCached = matchedUrl } } } } else -> { null } } } companion object { private const val TAG = "MainActivity" private var sharedUrlCached = "" } } ================================================ FILE: app/src/main/java/com/junkfood/seal/NotificationActionReceiver.kt ================================================ package com.junkfood.seal import android.content.BroadcastReceiver import android.content.ClipData import android.content.Context import android.content.Intent import android.util.Log import com.junkfood.seal.App.Companion.context import com.junkfood.seal.download.DownloaderV2 import com.junkfood.seal.util.NotificationUtil import com.junkfood.seal.util.ToastUtil import com.yausername.youtubedl_android.YoutubeDL import org.koin.core.component.KoinComponent import org.koin.core.component.get class NotificationActionReceiver : BroadcastReceiver(), KoinComponent { val downloader = get() companion object { private const val TAG = "CancelReceiver" private const val PACKAGE_NAME_PREFIX = "com.junkfood.seal." const val ACTION_CANCEL_TASK = 0 const val ACTION_ERROR_REPORT = 1 const val ACTION_KEY = PACKAGE_NAME_PREFIX + "action" const val TASK_ID_KEY = PACKAGE_NAME_PREFIX + "taskId" const val NOTIFICATION_ID_KEY = PACKAGE_NAME_PREFIX + "notificationId" const val ERROR_REPORT_KEY = PACKAGE_NAME_PREFIX + "error_report" } override fun onReceive(context: Context?, intent: Intent?) { if (intent == null) return val notificationId = intent.getIntExtra(NOTIFICATION_ID_KEY, 0) val action = intent.getIntExtra(ACTION_KEY, ACTION_CANCEL_TASK) Log.d(TAG, "onReceive: $action") when (action) { ACTION_CANCEL_TASK -> { val taskId = intent.getStringExtra(TASK_ID_KEY) cancelTask(taskId, notificationId) } ACTION_ERROR_REPORT -> { val errorReport = intent.getStringExtra(ERROR_REPORT_KEY) if (!errorReport.isNullOrEmpty()) copyErrorReport(errorReport, notificationId) } } } private fun cancelTask(taskId: String?, notificationId: Int) { if (taskId.isNullOrEmpty()) return NotificationUtil.cancelNotification(notificationId) val res = downloader.cancel(taskId) if (res) { Log.d(TAG, "Task (id:$taskId) was killed.") } else { // todo: reserved for custom commands YoutubeDL.destroyProcessById(taskId) Downloader.onProcessCanceled(taskId) } } private fun copyErrorReport(error: String, notificationId: Int) { App.clipboard.setPrimaryClip(ClipData.newPlainText(null, error)) context.let { ToastUtil.makeToastSuspend(it.getString(R.string.error_copied)) } NotificationUtil.cancelNotification(notificationId) } } ================================================ FILE: app/src/main/java/com/junkfood/seal/QuickDownloadActivity.kt ================================================ package com.junkfood.seal import android.content.Intent import android.graphics.drawable.ColorDrawable import android.os.Build import android.os.Bundle import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.junkfood.seal.ui.common.LocalDarkTheme import com.junkfood.seal.ui.common.SettingsProvider import com.junkfood.seal.ui.page.downloadv2.configure.Config import com.junkfood.seal.ui.page.downloadv2.configure.DownloadDialog import com.junkfood.seal.ui.page.downloadv2.configure.DownloadDialogViewModel import com.junkfood.seal.ui.page.downloadv2.configure.DownloadDialogViewModel.Action import com.junkfood.seal.ui.page.downloadv2.configure.DownloadDialogViewModel.SelectionState import com.junkfood.seal.ui.page.downloadv2.configure.FormatPage import com.junkfood.seal.ui.page.downloadv2.configure.PlaylistSelectionPage import com.junkfood.seal.ui.theme.SealTheme import com.junkfood.seal.util.DownloadUtil import com.junkfood.seal.util.PreferenceUtil import com.junkfood.seal.util.matchUrlFromSharedText import com.junkfood.seal.util.setLanguage import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.koin.androidx.viewmodel.ext.android.getViewModel private const val TAG = "QuickDownloadActivity" class QuickDownloadActivity : ComponentActivity() { private var sharedUrlCached: String = "" private fun Intent.getSharedURL(): String? { val intent = this return when (intent.action) { Intent.ACTION_VIEW -> { intent.dataString } Intent.ACTION_SEND -> { intent.getStringExtra(Intent.EXTRA_TEXT)?.let { sharedContent -> intent.removeExtra(Intent.EXTRA_TEXT) matchUrlFromSharedText(sharedContent) } } else -> { null } } } @OptIn(ExperimentalMaterial3WindowSizeClassApi::class, ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) intent.getSharedURL()?.let { sharedUrlCached = it } if (sharedUrlCached.isEmpty()) { finish() } App.startService() enableEdgeToEdge() window.run { setBackgroundDrawable(ColorDrawable(0)) setLayout( WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT, ) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY) } else { setType(WindowManager.LayoutParams.TYPE_SYSTEM_ALERT) } } if (Build.VERSION.SDK_INT < 33) { runBlocking { setLanguage(PreferenceUtil.getLocaleFromPreference()) } } val viewModel: DownloadDialogViewModel = getViewModel() viewModel.postAction(Action.ShowSheet(listOf(sharedUrlCached))) setContent { SettingsProvider(calculateWindowSizeClass(this).widthSizeClass) { SealTheme( darkTheme = LocalDarkTheme.current.isDarkTheme(), isHighContrastModeEnabled = LocalDarkTheme.current.isHighContrastModeEnabled, ) { var preferences by remember { mutableStateOf(DownloadUtil.DownloadPreferences.createFromPreferences()) } val sheetValue = viewModel.sheetValueFlow.collectAsStateWithLifecycle().value val state = viewModel.sheetStateFlow.collectAsStateWithLifecycle().value val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val selectionState = viewModel.selectionStateFlow.collectAsStateWithLifecycle().value var showDialog by remember { mutableStateOf(false) } LaunchedEffect(sheetValue, selectionState) { if (sheetValue == DownloadDialogViewModel.SheetValue.Expanded) { showDialog = true } else if (sheetValue == DownloadDialogViewModel.SheetValue.Hidden) { launch { sheetState.hide() } .invokeOnCompletion { showDialog = false if (selectionState == SelectionState.Idle) { this@QuickDownloadActivity.finish() } } } } if (showDialog) { DownloadDialog( state = state, sheetState = sheetState, config = Config(), preferences = preferences, onPreferencesUpdate = { preferences = it }, onActionPost = { viewModel.postAction(it) }, ) } when (selectionState) { is SelectionState.FormatSelection -> FormatPage( state = selectionState, onDismissRequest = { viewModel.postAction(Action.Reset) this.finish() }, ) SelectionState.Idle -> {} is SelectionState.PlaylistSelection -> { PlaylistSelectionPage( state = selectionState, onDismissRequest = { viewModel.postAction(Action.Reset) this.finish() }, ) } } } } } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/database/AppDatabase.kt ================================================ package com.junkfood.seal.database import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase import com.junkfood.seal.database.objects.CommandTemplate import com.junkfood.seal.database.objects.CookieProfile import com.junkfood.seal.database.objects.DownloadedVideoInfo import com.junkfood.seal.database.objects.OptionShortcut @Database( entities = [ DownloadedVideoInfo::class, CommandTemplate::class, CookieProfile::class, OptionShortcut::class, ], version = 5, autoMigrations = [ AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3), AutoMigration(from = 3, to = 4), AutoMigration(from = 4, to = 5), ], ) abstract class AppDatabase : RoomDatabase() { abstract fun videoInfoDao(): VideoInfoDao } ================================================ FILE: app/src/main/java/com/junkfood/seal/database/VideoInfoDao.kt ================================================ package com.junkfood.seal.database import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.Query import androidx.room.Transaction import androidx.room.Update import com.junkfood.seal.database.objects.CommandTemplate import com.junkfood.seal.database.objects.CookieProfile import com.junkfood.seal.database.objects.DownloadedVideoInfo import com.junkfood.seal.database.objects.OptionShortcut import kotlinx.coroutines.flow.Flow @Dao interface VideoInfoDao { @Insert suspend fun insert(info: DownloadedVideoInfo) @Insert suspend fun insertAll(infoList: List) @Query("select * from DownloadedVideoInfo") fun getDownloadHistoryFlow(): Flow> @Query("select * from DownloadedVideoInfo") suspend fun getDownloadHistory(): List @Query("select * from DownloadedVideoInfo where id=:id") suspend fun getInfoById(id: Int): DownloadedVideoInfo @Query("DELETE FROM DownloadedVideoInfo WHERE id = :id") suspend fun deleteInfoById(id: Int) @Query("DELETE FROM DownloadedVideoInfo WHERE videoPath = :path") suspend fun deleteInfoByPath(path: String) @Query("select * from DownloadedVideoInfo where videoPath = :path") suspend fun getInfoByPath(path: String): DownloadedVideoInfo? @Transaction suspend fun insertInfoDistinctByPath( videoInfo: DownloadedVideoInfo, path: String = videoInfo.videoPath, ) { if (getInfoByPath(path) == null) insert(videoInfo) } @Delete suspend fun deleteInfo(vararg info: DownloadedVideoInfo) @Delete @Transaction suspend fun deleteInfoList(idList: List) @Query("SELECT * FROM CommandTemplate") fun getTemplateFlow(): Flow> @Query("SELECT * FROM CommandTemplate") suspend fun getTemplateList(): List @Query("select * from CookieProfile") fun getCookieProfileFlow(): Flow> @Insert suspend fun insertTemplate(template: CommandTemplate): Long @Insert @Transaction suspend fun importTemplates(templateList: List) @Update suspend fun updateTemplate(template: CommandTemplate) @Delete suspend fun deleteTemplate(template: CommandTemplate) @Query("SELECT * FROM CommandTemplate where id = :id") suspend fun getTemplateById(id: Int): CommandTemplate @Query("select * from CookieProfile where id=:id") suspend fun getCookieById(id: Int): CookieProfile? @Update suspend fun updateCookieProfile(cookieProfile: CookieProfile) @Delete suspend fun deleteCookieProfile(cookieProfile: CookieProfile) @Insert suspend fun insertCookieProfile(cookieProfile: CookieProfile) @Query("delete from CommandTemplate where id=:id") suspend fun deleteTemplateById(id: Int) @Delete suspend fun deleteTemplates(templates: List) @Query("select * from OptionShortcut") fun getOptionShortcuts(): Flow> @Query("select * from OptionShortcut") suspend fun getShortcutList(): List @Delete suspend fun deleteShortcut(optionShortcut: OptionShortcut) @Insert suspend fun insertShortcut(optionShortcut: OptionShortcut): Long @Transaction @Insert suspend fun insertAllShortcuts(shortcuts: List) } ================================================ FILE: app/src/main/java/com/junkfood/seal/database/backup/Backup.kt ================================================ package com.junkfood.seal.database.backup import com.junkfood.seal.database.objects.CommandTemplate import com.junkfood.seal.database.objects.DownloadedVideoInfo import com.junkfood.seal.database.objects.OptionShortcut import kotlinx.serialization.Serializable @Serializable data class Backup( val templates: List? = null, val shortcuts: List? = null, val downloadHistory: List? = null, ) ================================================ FILE: app/src/main/java/com/junkfood/seal/database/backup/BackupUtil.kt ================================================ package com.junkfood.seal.database.backup import android.content.Context import com.junkfood.seal.App import com.junkfood.seal.R import com.junkfood.seal.database.objects.CommandTemplate import com.junkfood.seal.database.objects.DownloadedVideoInfo import com.junkfood.seal.database.objects.OptionShortcut import com.junkfood.seal.util.DatabaseUtil import java.util.Date import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json object BackupUtil { private val format = Json { prettyPrint = true ignoreUnknownKeys = true } suspend fun exportTemplatesToJson() = exportTemplatesToJson( templates = DatabaseUtil.getTemplateList(), shortcuts = DatabaseUtil.getShortcutList(), ) fun exportTemplatesToJson( templates: List, shortcuts: List, ): String { return format.encodeToString(Backup(templates = templates, shortcuts = shortcuts)) } fun List.toJsonString(): String { return format.encodeToString(Backup(downloadHistory = this)) } fun List.toURLListString(): String { return this.map { it.videoUrl }.joinToString(separator = "\n") { it } } fun String.decodeToBackup(): Result { return format.runCatching { decodeFromString(this@decodeToBackup) } } fun getDownloadHistoryExportFilename(context: Context): String { return listOf( context.getString(R.string.app_name), App.packageInfo.versionName.toString(), Date().toString(), ) .joinToString(separator = "-") { it } } enum class BackupDestination { File, Clipboard, } enum class BackupType { DownloadHistory, URLList, CommandTemplate, CommandShortcut, } } ================================================ FILE: app/src/main/java/com/junkfood/seal/database/objects/CommandTemplate.kt ================================================ package com.junkfood.seal.database.objects import androidx.room.Entity import androidx.room.PrimaryKey import kotlinx.serialization.Serializable @Entity @Serializable data class CommandTemplate( @PrimaryKey(autoGenerate = true) val id: Int, val name: String, val template: String, ) ================================================ FILE: app/src/main/java/com/junkfood/seal/database/objects/CookieProfile.kt ================================================ package com.junkfood.seal.database.objects import androidx.room.Entity import androidx.room.PrimaryKey import kotlinx.serialization.Serializable @Entity @Serializable data class CookieProfile( @PrimaryKey(autoGenerate = true) val id: Int, val url: String, val content: String, ) ================================================ FILE: app/src/main/java/com/junkfood/seal/database/objects/DownloadedVideoInfo.kt ================================================ package com.junkfood.seal.database.objects import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Ignore import androidx.room.PrimaryKey import kotlinx.serialization.Serializable @Entity @Serializable data class DownloadedVideoInfo( @PrimaryKey(autoGenerate = true) val id: Int, val videoTitle: String, val videoAuthor: String, val videoUrl: String, val thumbnailUrl: String, val videoPath: String, @ColumnInfo(defaultValue = "Unknown") val extractor: String = "Unknown", ) { @Ignore constructor() : this( id = 0, videoTitle = "Video", videoAuthor = "Author", videoUrl = "Url", thumbnailUrl = "Thumbnail", videoPath = "Path", extractor = "Unknown", ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/database/objects/OptionShortcut.kt ================================================ package com.junkfood.seal.database.objects import androidx.room.Entity import androidx.room.PrimaryKey import kotlinx.serialization.Serializable @Entity @Serializable data class OptionShortcut(@PrimaryKey(autoGenerate = true) val id: Long = 0, val option: String) ================================================ FILE: app/src/main/java/com/junkfood/seal/download/DownloaderV2.kt ================================================ package com.junkfood.seal.download import android.app.PendingIntent import android.content.Context import android.util.Log import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshots.SnapshotStateMap import com.junkfood.seal.App import com.junkfood.seal.R import com.junkfood.seal.download.Task.DownloadState import com.junkfood.seal.download.Task.DownloadState.Canceled import com.junkfood.seal.download.Task.DownloadState.Completed import com.junkfood.seal.download.Task.DownloadState.Error import com.junkfood.seal.download.Task.DownloadState.FetchingInfo import com.junkfood.seal.download.Task.DownloadState.Idle import com.junkfood.seal.download.Task.DownloadState.ReadyWithInfo import com.junkfood.seal.download.Task.DownloadState.Running import com.junkfood.seal.download.Task.RestartableAction.Download import com.junkfood.seal.download.Task.RestartableAction.FetchInfo import com.junkfood.seal.download.Task.TypeInfo import com.junkfood.seal.util.DownloadUtil import com.junkfood.seal.util.FileUtil import com.junkfood.seal.util.NotificationUtil import com.junkfood.seal.util.PreferenceUtil import com.junkfood.seal.util.VideoInfo import com.yausername.youtubedl_android.YoutubeDL import kotlin.collections.component1 import kotlin.collections.component2 import kotlin.collections.set import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent private const val TAG = "DownloaderV2" private const val MAX_CONCURRENCY = 3 interface DownloaderV2 { fun getTaskStateMap(): SnapshotStateMap fun cancel(task: Task): Boolean fun cancel(taskId: String): Boolean { return getTaskStateMap().keys.find { it.id == taskId }?.let { cancel(it) } ?: false } fun restart(task: Task) /** Enqueue a [Task] with an empty [Task.State] */ fun enqueue(task: Task) fun enqueue(task: Task, state: Task.State) fun enqueue(taskWithState: TaskFactory.TaskWithState) { val (task, state) = taskWithState enqueue(task, state) } fun remove(task: Task): Boolean } internal object FakeDownloaderV2 : DownloaderV2 { override fun getTaskStateMap(): SnapshotStateMap { return mutableStateMapOf() } override fun cancel(task: Task): Boolean { return false } override fun restart(task: Task) {} override fun enqueue(task: Task) {} override fun enqueue(task: Task, state: Task.State) {} override fun remove(task: Task): Boolean { return true } } /** * TODO: * - Notification * - Custom commands * - States for ViewModels */ @OptIn(FlowPreview::class) class DownloaderV2Impl(private val appContext: Context) : DownloaderV2, KoinComponent { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val taskStateMap = mutableStateMapOf() private val snapshotFlow = snapshotFlow { taskStateMap.toMap() } init { scope.launch(Dispatchers.Default) { snapshotFlow .onEach { doYourWork() } .map { it.countRunning() } .distinctUntilChanged() .collect { if (it > 0) App.startService() else App.stopService() } } scope.launch(Dispatchers.IO) { // don't write before we read enqueueFromBackup() snapshotFlow .map { it.filter { it.value.downloadState !is Completed } } .distinctUntilChanged() .collect { it.forEach { Log.d(TAG, it.value.viewState.title) } PreferenceUtil.encodeTaskListBackup(it) } } } private fun enqueueFromBackup() { val taskList = PreferenceUtil.decodeTaskListBackup() .filter { it.value.downloadState !is Completed } .mapValues { (_, state) -> val preState = state.downloadState val downloadState = when (preState) { is FetchingInfo, Idle -> { Canceled(action = FetchInfo) } is Running -> { Canceled(action = Download, progress = preState.progress) } ReadyWithInfo -> { Canceled(action = Download, progress = null) } else -> { preState } } state.copy(downloadState = downloadState) } taskList.forEach(::enqueue) } private fun Map.countRunning(): Int = count { (_, state) -> state.downloadState is Running || state.downloadState is FetchingInfo } override fun getTaskStateMap(): SnapshotStateMap { return taskStateMap } override fun enqueue(task: Task) { taskStateMap += task to Task.State(Idle, null, Task.ViewState(url = task.url, title = task.url)) } override fun enqueue(task: Task, state: Task.State) { taskStateMap += task to state } /** * Noted the caller is responsible for stopping the [task] before removing it * * @return true if the task was removed */ override fun remove(task: Task): Boolean { if (taskStateMap.contains(task)) { taskStateMap.remove(task) return true } return false } override fun cancel(task: Task): Boolean = task.cancelImpl() override fun restart(task: Task) { task.restartImpl() } private var Task.state: Task.State get() = taskStateMap[this]!! set(value) { taskStateMap[this] = value } private var Task.downloadState: DownloadState get() = state.downloadState set(value) { val prevState = state taskStateMap[this] = prevState.copy(downloadState = value) } private var Task.info: VideoInfo? get() = state.videoInfo set(value) { val prevState = state taskStateMap[this] = prevState.copy(videoInfo = value) } private var Task.viewState: Task.ViewState get() = state.viewState set(value) { val prevState = state taskStateMap[this] = prevState.copy(viewState = value) } private val Task.notificationId: Int get() = id.hashCode() /** Processes pending tasks, prioritizing downloads. */ private fun doYourWork() { if (taskStateMap.countRunning() >= MAX_CONCURRENCY) return taskStateMap.entries .sortedBy { (_, state) -> state.downloadState } .firstOrNull { (_, state) -> state.downloadState == ReadyWithInfo || state.downloadState == Idle } ?.let { (task, state) -> when (state.downloadState) { Idle -> task.prepare() ReadyWithInfo -> task.download() else -> { throw IllegalStateException() } } } } private fun Task.prepare() { check(downloadState == Idle) if (type is TypeInfo.CustomCommand) { execute() } else { fetchInfo() } } private fun Task.fetchInfo() { check(downloadState == Idle) val task = this val taskInfo = task.type val playlistIndex = if (taskInfo is TypeInfo.Playlist) taskInfo.index else null scope .launch(Dispatchers.Default) { DownloadUtil.fetchVideoInfoFromUrl( url = url, playlistIndex = playlistIndex, preferences = preferences, taskKey = id, ) .onSuccess { info = it downloadState = ReadyWithInfo viewState = Task.ViewState.fromVideoInfo(it) } .onFailure { throwable -> if (throwable is YoutubeDL.CanceledException) { return@onFailure } task.downloadState = Error(throwable = throwable, action = FetchInfo) NotificationUtil.notifyError( title = viewState.title, textId = R.string.download_error_msg, notificationId = notificationId, report = throwable.stackTraceToString(), ) } } .also { job -> downloadState = FetchingInfo(job = job, taskId = id) } } private fun Task.download() { check(downloadState == ReadyWithInfo && info != null) if (type is TypeInfo.CustomCommand) { execute() return } scope .launch(Dispatchers.Default) { DownloadUtil.downloadVideo( videoInfo = info, taskId = id, downloadPreferences = preferences, progressCallback = { progressPercentage, _, text -> val progress = progressPercentage / 100f when (val preState = downloadState) { is Running -> { downloadState = preState.copy(progress = progress, progressText = text) NotificationUtil.notifyProgress( notificationId = notificationId, progress = progressPercentage.toInt(), text = text, title = viewState.title, taskId = id, ) } else -> {} } }, ) .onSuccess { pathList -> downloadState = Completed(pathList.firstOrNull()) val text = appContext.getString( if (pathList.isEmpty()) R.string.status_completed else R.string.download_finish_notification ) FileUtil.createIntentForOpeningFile(pathList.firstOrNull()).run { NotificationUtil.finishNotification( notificationId, title = viewState.title, text = text, intent = if (this != null) PendingIntent.getActivity( appContext, 0, this, PendingIntent.FLAG_IMMUTABLE, ) else null, ) } } .onFailure { throwable -> if (throwable is YoutubeDL.CanceledException) { return@onFailure } downloadState = Error(throwable = throwable, action = Download) NotificationUtil.notifyError( title = viewState.title, textId = R.string.fetch_info_error_msg, notificationId = notificationId, report = throwable.stackTraceToString(), ) } } .also { job -> downloadState = Running(job = job, taskId = id) } } private fun Task.cancelImpl(): Boolean { when (val preState = downloadState) { is DownloadState.Cancelable -> { val res = YoutubeDL.destroyProcessById(preState.taskId) if (res) { preState.job.cancel() val progress = if (preState is Running) preState.progress else null NotificationUtil.cancelNotification(notificationId) downloadState = DownloadState.Canceled(action = preState.action, progress = progress) } return res } Idle -> { downloadState = DownloadState.Canceled(action = FetchInfo) } ReadyWithInfo -> { downloadState = DownloadState.Canceled(action = Download) } else -> { return false } } return true } private fun Task.restartImpl() { when (val preState = downloadState) { is DownloadState.Restartable -> { downloadState = when (preState.action) { Download -> ReadyWithInfo FetchInfo -> Idle } } else -> { throw IllegalStateException() } } } /** * Execute a custom command task * * @see Task.TypeInfo.CustomCommand */ private fun Task.execute() { check(downloadState == Idle) check(type is TypeInfo.CustomCommand) val template = type.template scope .launch { DownloadUtil.executeCustomCommandTask(url, id, template, preferences) { progressPercentage, _, text -> val progress = progressPercentage / 100f when (val preState = downloadState) { is Running -> { downloadState = preState.copy(progress = progress, progressText = text) NotificationUtil.makeNotificationForCustomCommand( notificationId = notificationId, taskId = id, progress = progressPercentage.toInt(), templateName = template.name, taskUrl = url, text = text, ) } else -> {} } } .onFailure { throwable -> if (throwable is YoutubeDL.CanceledException) { return@onFailure } downloadState = Error(throwable = throwable, action = Download) NotificationUtil.notifyError( title = viewState.title, textId = R.string.fetch_info_error_msg, notificationId = notificationId, report = throwable.stackTraceToString(), ) } .onSuccess { downloadState = Completed(null) val text = appContext.getString(R.string.status_completed) NotificationUtil.finishNotification( notificationId = notificationId, title = viewState.title, text = text, intent = null, ) } } .also { downloadState = Running(job = it, taskId = id) } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/download/Task.kt ================================================ package com.junkfood.seal.download import com.junkfood.seal.database.objects.CommandTemplate import com.junkfood.seal.download.Task.TypeInfo import com.junkfood.seal.download.Task.ViewState import com.junkfood.seal.util.DownloadUtil import com.junkfood.seal.util.Format import com.junkfood.seal.util.VideoInfo import com.junkfood.seal.util.toHttpsUrl import kotlinx.coroutines.Job import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import kotlin.math.roundToInt private val TypeInfo.id: String get() = when (this) { is TypeInfo.CustomCommand -> "${template.id}_${template.name}" is TypeInfo.Playlist -> "$index" TypeInfo.URL -> "" } private fun makeId(url: String, type: TypeInfo, preferences: DownloadUtil.DownloadPreferences): String = "${url}_${type.id}_${preferences.hashCode()}" @Serializable data class Task( val url: String, val type: TypeInfo = TypeInfo.URL, val preferences: DownloadUtil.DownloadPreferences, val id: String = makeId(url, type, preferences), ) : Comparable { val timeCreated: Long = System.currentTimeMillis() override fun compareTo(other: Task): Int { return timeCreated.compareTo(other.timeCreated) } @Serializable sealed interface TypeInfo { @Serializable data class Playlist(val index: Int = 0) : TypeInfo @Serializable data class CustomCommand(val template: CommandTemplate) : TypeInfo @Serializable data object URL : TypeInfo } @Serializable data class State( val downloadState: DownloadState, val videoInfo: VideoInfo?, val viewState: ViewState, ) @Serializable sealed interface DownloadState : Comparable { interface Cancelable { val job: Job val taskId: String val action: RestartableAction } interface Restartable { val action: RestartableAction } @Serializable data object Idle : DownloadState @Serializable data class FetchingInfo( @Transient override val job: Job = Job(), override val taskId: String, ) : DownloadState, Cancelable { override val action: RestartableAction = RestartableAction.FetchInfo } @Serializable data object ReadyWithInfo : DownloadState @Serializable data class Running( @Transient override val job: Job = Job(), override val taskId: String, val progress: Float = PROGRESS_INDETERMINATE, val progressText: String = "", ) : DownloadState, Cancelable { override val action: RestartableAction = RestartableAction.Download } @Serializable data class Canceled(override val action: RestartableAction, val progress: Float? = null) : DownloadState, Restartable @Serializable data class Error( @Transient val throwable: Throwable = Throwable(), override val action: RestartableAction, ) : DownloadState, Restartable @Serializable data class Completed(val filePath: String?) : DownloadState override fun compareTo(other: DownloadState): Int { return ordinal - other.ordinal } private val ordinal: Int get() = when (this) { is Canceled -> 4 is Error -> 5 is Completed -> 6 Idle -> 3 is FetchingInfo -> 2 ReadyWithInfo -> 1 is Running -> 0 } } @Serializable sealed interface RestartableAction { @Serializable data object FetchInfo : RestartableAction @Serializable data object Download : RestartableAction } @Serializable data class ViewState( val url: String = "https://www.example.com", val title: String = "", val uploader: String = "", val extractorKey: String = "", val duration: Int = 0, val fileSizeApprox: Double = .0, val thumbnailUrl: String? = null, val videoFormats: List? = null, val audioOnlyFormats: List? = null, ) { companion object { fun fromVideoInfo(info: VideoInfo): ViewState { val formats = info.requestedFormats ?: info.requestedDownloads?.map { it.toFormat() } ?: emptyList() val videoFormats = formats.filter { it.containsVideo() } val audioOnlyFormats = formats.filter { it.isAudioOnly() } return ViewState( url = info.originalUrl.toString(), title = info.title, uploader = info.uploader ?: info.channel ?: info.uploaderId.toString(), extractorKey = info.extractorKey, duration = info.duration?.roundToInt() ?: 0, thumbnailUrl = info.thumbnail.toHttpsUrl(), fileSizeApprox = info.fileSize ?: info.fileSizeApprox ?: .0, videoFormats = videoFormats, audioOnlyFormats = audioOnlyFormats, ) } } } companion object { private const val PROGRESS_INDETERMINATE = -1f } } ================================================ FILE: app/src/main/java/com/junkfood/seal/download/TaskFactory.kt ================================================ package com.junkfood.seal.download import androidx.annotation.CheckResult import com.junkfood.seal.download.Task.DownloadState.Idle import com.junkfood.seal.download.Task.DownloadState.ReadyWithInfo import com.junkfood.seal.util.DownloadUtil.DownloadPreferences import com.junkfood.seal.util.Format import com.junkfood.seal.util.PlaylistResult import com.junkfood.seal.util.VideoClip import com.junkfood.seal.util.VideoInfo import kotlin.math.roundToInt object TaskFactory { /** * @return A [TaskWithState] with extra configurations made by user in the custom format * selection page */ @CheckResult fun createWithConfigurations( videoInfo: VideoInfo, formatList: List, videoClips: List, splitByChapter: Boolean, newTitle: String, selectedSubtitles: List, selectedAutoCaptions: List, ): TaskWithState { val fileSize = formatList.fold(.0) { acc, format -> acc + (format.fileSize ?: format.fileSizeApprox ?: .0) } val info = videoInfo .run { if (fileSize != .0) copy(fileSize = fileSize) else this } .run { if (newTitle.isNotEmpty()) copy(title = newTitle) else this } val audioOnlyFormats = formatList.filter { it.isAudioOnly() } val videoFormats = formatList.filter { it.containsVideo() } val audioOnly = audioOnlyFormats.isNotEmpty() && videoFormats.isEmpty() val mergeAudioStream = audioOnlyFormats.size > 1 val formatId = formatList.joinToString(separator = "+") { it.formatId.toString() } val subtitleLanguage = (selectedSubtitles + selectedAutoCaptions).joinToString(separator = ",") val preferences = DownloadPreferences.createFromPreferences() .run { copy( formatIdString = formatId, videoClips = videoClips, splitByChapter = splitByChapter, newTitle = newTitle, mergeAudioStream = mergeAudioStream, extractAudio = extractAudio || audioOnly, ) } .run { if (subtitleLanguage.isNotEmpty()) { copy( downloadSubtitle = true, autoSubtitle = selectedAutoCaptions.isNotEmpty(), subtitleLanguage = subtitleLanguage, ) } else { this } } val task = Task(url = info.originalUrl.toString(), preferences = preferences) val state = Task.State( downloadState = ReadyWithInfo, videoInfo = info, viewState = Task.ViewState.fromVideoInfo(info = info) .copy(videoFormats = videoFormats, audioOnlyFormats = audioOnlyFormats), ) return TaskWithState(task, state) } /** @return List of [TaskWithState]s created from playlist items */ @CheckResult fun createWithPlaylistResult( playlistUrl: String, indexList: List, playlistResult: PlaylistResult, preferences: DownloadPreferences, ): List { checkNotNull(playlistResult.entries) val indexEntryMap = indexList.associateWith { index -> playlistResult.entries[index - 1] } val taskList = indexEntryMap.map { (index, entry) -> val viewState = Task.ViewState( url = entry.url ?: "", title = entry.title ?: "${playlistResult.title} - $index", duration = entry.duration?.roundToInt() ?: 0, uploader = entry.uploader ?: entry.channel ?: playlistResult.channel ?: "", thumbnailUrl = (entry.thumbnails?.lastOrNull()?.url) ?: "", ) val task = Task(url = playlistUrl, preferences = preferences, type = Task.TypeInfo.Playlist(index)) val state = Task.State(downloadState = Idle, videoInfo = null, viewState = viewState) TaskWithState(task, state) } return taskList } data class TaskWithState(val task: Task, val state: Task.State) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/common/AnimatedComposable.kt ================================================ package com.junkfood.seal.ui.common import android.os.Build import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.Spring import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutVertically import androidx.compose.runtime.Composable import androidx.compose.ui.unit.IntOffset import androidx.navigation.NamedNavArgument import androidx.navigation.NavBackStackEntry import androidx.navigation.NavDeepLink import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import com.junkfood.seal.ui.common.motion.EmphasizeEasing import com.junkfood.seal.ui.common.motion.EmphasizedAccelerate import com.junkfood.seal.ui.common.motion.EmphasizedDecelerate import com.junkfood.seal.ui.common.motion.materialSharedAxisXIn import com.junkfood.seal.ui.common.motion.materialSharedAxisXOut const val DURATION_ENTER = 400 const val DURATION_EXIT = 200 const val initialOffset = 0.10f private fun enterTween() = tween(durationMillis = DURATION_ENTER, easing = EmphasizeEasing) private fun exitTween() = tween(durationMillis = DURATION_ENTER, easing = EmphasizeEasing) private val fadeSpring = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMedium) private val fadeTween = tween(durationMillis = DURATION_EXIT) private val fadeSpec = fadeTween fun NavGraphBuilder.animatedComposable( route: String, arguments: List = emptyList(), deepLinks: List = emptyList(), usePredictiveBack: Boolean = Build.VERSION.SDK_INT >= 34, content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit, ) { if (usePredictiveBack) { animatedComposablePredictiveBack(route, arguments, deepLinks, content) } else { animatedComposableLegacy(route, arguments, deepLinks, content) } } fun NavGraphBuilder.animatedComposablePredictiveBack( route: String, arguments: List = emptyList(), deepLinks: List = emptyList(), content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit, ) = composable( route = route, arguments = arguments, deepLinks = deepLinks, enterTransition = { materialSharedAxisXIn(initialOffsetX = { (it * 0.15f).toInt() }) }, exitTransition = { materialSharedAxisXOut(targetOffsetX = { -(it * initialOffset).toInt() }) }, popEnterTransition = { scaleIn( animationSpec = tween(durationMillis = 350, easing = EmphasizedDecelerate), initialScale = 0.9f, ) + materialSharedAxisXIn(initialOffsetX = { -(it * initialOffset).toInt() }) }, popExitTransition = { materialSharedAxisXOut(targetOffsetX = { (it * initialOffset).toInt() }) + scaleOut( targetScale = 0.9f, animationSpec = tween(durationMillis = 350, easing = EmphasizedAccelerate), ) }, content = content, ) fun NavGraphBuilder.animatedComposableLegacy( route: String, arguments: List = emptyList(), deepLinks: List = emptyList(), content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit, ) = composable( route = route, arguments = arguments, deepLinks = deepLinks, enterTransition = { materialSharedAxisXIn(initialOffsetX = { (it * initialOffset).toInt() }) }, exitTransition = { materialSharedAxisXOut(targetOffsetX = { -(it * initialOffset).toInt() }) }, popEnterTransition = { materialSharedAxisXIn(initialOffsetX = { -(it * initialOffset).toInt() }) }, popExitTransition = { materialSharedAxisXOut(targetOffsetX = { (it * initialOffset).toInt() }) }, content = content, ) fun NavGraphBuilder.animatedComposableVariant( route: String, arguments: List = emptyList(), deepLinks: List = emptyList(), content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit, ) = composable( route = route, arguments = arguments, deepLinks = deepLinks, enterTransition = { slideInHorizontally(enterTween(), initialOffsetX = { (it * initialOffset).toInt() }) + fadeIn(fadeSpec) }, exitTransition = { fadeOut(fadeSpec) }, popEnterTransition = { fadeIn(fadeSpec) }, popExitTransition = { slideOutHorizontally(exitTween(), targetOffsetX = { (it * initialOffset).toInt() }) + fadeOut(fadeSpec) }, content = content, ) val springSpec = spring(stiffness = Spring.StiffnessMedium, visibilityThreshold = IntOffset.VisibilityThreshold) @OptIn(ExperimentalAnimationApi::class) fun NavGraphBuilder.slideInVerticallyComposable( route: String, arguments: List = emptyList(), deepLinks: List = emptyList(), content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit, ) = composable( route = route, arguments = arguments, deepLinks = deepLinks, enterTransition = { slideInVertically(initialOffsetY = { it }, animationSpec = enterTween()) + fadeIn() }, exitTransition = { slideOutVertically() }, popEnterTransition = { slideInVertically() }, popExitTransition = { slideOutVertically(targetOffsetY = { it }, animationSpec = enterTween()) + fadeOut() }, content = content, ) ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/common/AsyncImageImpl.kt ================================================ package com.junkfood.seal.ui.common import androidx.compose.foundation.Image import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.DefaultAlpha import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource import coil.compose.AsyncImagePainter import coil.imageLoader import coil.request.ImageRequest import com.junkfood.seal.R @Composable fun AsyncImageImpl( model: Any?, contentDescription: String?, modifier: Modifier = Modifier, transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform, onState: ((AsyncImagePainter.State) -> Unit)? = null, alignment: Alignment = Alignment.Center, contentScale: ContentScale = ContentScale.Fit, alpha: Float = DefaultAlpha, colorFilter: ColorFilter? = null, filterQuality: FilterQuality = DrawScope.DefaultFilterQuality, isPreview: Boolean = LocalInspectionMode.current, ) { if (isPreview) Image( painter = painterResource(R.drawable.sample3), contentDescription = contentDescription, modifier = modifier, alignment = alignment, contentScale = contentScale, alpha = alpha, colorFilter = colorFilter, ) else coil.compose.AsyncImage( model = ImageRequest.Builder(LocalContext.current).data(model).crossfade(true).build(), contentDescription = contentDescription, imageLoader = LocalContext.current.imageLoader, modifier = modifier, transform = transform, onState = onState, alignment = alignment, contentScale = contentScale, alpha = alpha, colorFilter = colorFilter, filterQuality = filterQuality, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/common/CompositionLocals.kt ================================================ package com.junkfood.seal.ui.common import android.os.Build import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import com.junkfood.seal.ui.theme.DEFAULT_SEED_COLOR import com.junkfood.seal.ui.theme.FixedColorRoles import com.junkfood.seal.util.DarkThemePreference import com.junkfood.seal.util.PreferenceUtil import com.junkfood.seal.util.paletteStyles import com.kyant.monet.LocalTonalPalettes import com.kyant.monet.PaletteStyle import com.kyant.monet.TonalPalettes.Companion.toTonalPalettes val LocalDarkTheme = compositionLocalOf { DarkThemePreference() } val LocalSeedColor = compositionLocalOf { DEFAULT_SEED_COLOR } val LocalWindowWidthState = staticCompositionLocalOf { WindowWidthSizeClass.Compact } val LocalDynamicColorSwitch = compositionLocalOf { false } val LocalPaletteStyleIndex = compositionLocalOf { 0 } val LocalFixedColorRoles = staticCompositionLocalOf { FixedColorRoles.fromColorSchemes( lightColors = lightColorScheme(), darkColors = darkColorScheme(), ) } @Composable fun SettingsProvider(windowWidthSizeClass: WindowWidthSizeClass, content: @Composable () -> Unit) { PreferenceUtil.AppSettingsStateFlow.collectAsState().value.run { val tonalPalettes = if (isDynamicColorEnabled && Build.VERSION.SDK_INT >= 31) dynamicDarkColorScheme(LocalContext.current).toTonalPalettes() else Color(seedColor) .toTonalPalettes( paletteStyles.getOrElse(paletteStyleIndex) { PaletteStyle.TonalSpot } ) CompositionLocalProvider( LocalDarkTheme provides darkTheme, LocalSeedColor provides seedColor, LocalPaletteStyleIndex provides paletteStyleIndex, LocalTonalPalettes provides tonalPalettes, LocalWindowWidthState provides windowWidthSizeClass, LocalDynamicColorSwitch provides isDynamicColorEnabled, content = content, ) } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/common/Ext.kt ================================================ package com.junkfood.seal.ui.common import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import com.junkfood.seal.util.PreferenceUtil.getBoolean import com.junkfood.seal.util.PreferenceUtil.getInt import com.junkfood.seal.util.PreferenceUtil.getString inline val String.booleanState @Composable get() = remember { mutableStateOf(this.getBoolean()) } inline val String.stringState @Composable get() = remember { mutableStateOf(this.getString()) } inline val String.intState @Composable get() = remember { mutableIntStateOf(this.getInt()) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/common/HapticFeedback.kt ================================================ package com.junkfood.seal.ui.common import android.view.HapticFeedbackConstants import android.view.View object HapticFeedback { fun View.slightHapticFeedback() = this.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) fun View.longPressHapticFeedback() = this.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/common/Route.kt ================================================ package com.junkfood.seal.ui.common object Route { const val HOME = "home" const val DOWNLOADS = "download_history" const val PLAYLIST = "playlist" const val SETTINGS = "settings" const val FORMAT_SELECTION = "format" const val TASK_LIST = "task_list" const val TASK_LOG = "task_log" const val SETTINGS_PAGE = "settings_page" const val APPEARANCE = "appearance" const val INTERACTION = "interaction" const val GENERAL_DOWNLOAD_PREFERENCES = "general_download_preferences" const val ABOUT = "about" const val DOWNLOAD_DIRECTORY = "download_directory" const val CREDITS = "credits" const val LANGUAGES = "languages" const val TEMPLATE = "template" const val TEMPLATE_EDIT = "template_edit" const val DARK_THEME = "dark_theme" const val DOWNLOAD_QUEUE = "queue" const val DOWNLOAD_FORMAT = "download_format" const val NETWORK_PREFERENCES = "network_preferences" const val COOKIE_PROFILE = "cookie_profile" const val COOKIE_GENERATOR_WEBVIEW = "cookie_webview" const val SUBTITLE_PREFERENCES = "subtitle_preferences" const val AUTO_UPDATE = "auto_update" const val DONATE = "donate" const val TROUBLESHOOTING = "troubleshooting" const val TASK_HASHCODE = "task_hashcode" const val TEMPLATE_ID = "template_id" } infix fun String.arg(arg: String) = "$this/{$arg}" infix fun String.id(id: Int) = "$this/$id" ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/common/motion/AnimationSpecs.kt ================================================ package com.junkfood.seal.ui.common.motion import android.view.animation.PathInterpolator import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.Easing import androidx.compose.animation.core.PathEasing import androidx.compose.animation.core.tween import androidx.compose.ui.graphics.Path import com.junkfood.seal.ui.common.DURATION_ENTER fun PathInterpolator.toEasing(): Easing { return Easing { f -> this.getInterpolation(f) } } private val path = Path().apply { moveTo(0f, 0f) cubicTo(0.05F, 0F, 0.133333F, 0.06F, 0.166666F, 0.4F) cubicTo(0.208333F, 0.82F, 0.25F, 1F, 1F, 1F) } val EmphasizeEasing = PathEasing(path) val EmphasizeEasingVariant = CubicBezierEasing(.2f, 0f, 0f, 1f) val EmphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f) val EmphasizedAccelerate = CubicBezierEasing(0.3f, 0f, 1f, 1f) private val standardDecelerate = CubicBezierEasing(.0f, .0f, 0f, 1f) private val motionEasingStandard = CubicBezierEasing(0.4F, 0.0F, 0.2F, 1F) private val tweenSpec = tween(durationMillis = DURATION_ENTER, easing = EmphasizeEasing) ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/common/motion/MaterialSharedAxis.kt ================================================ package com.junkfood.seal.ui.common.motion /* * Copyright 2021 SOUP * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp /** * Returns the provided [Dp] as an [Int] value by the [LocalDensity]. * * @param slideDistance Value to the slide distance dimension, 30dp by default. */ @Composable public fun rememberSlideDistance(slideDistance: Dp = MotionConstants.DefaultSlideDistance): Int { val density = LocalDensity.current return remember(density, slideDistance) { with(density) { slideDistance.roundToPx() } } } private const val ProgressThreshold = 0.35f private val Int.ForOutgoing: Int get() = (this * ProgressThreshold).toInt() private val Int.ForIncoming: Int get() = this - this.ForOutgoing /** [materialSharedAxisX] allows to switch a layout with shared X-axis transition. */ public fun materialSharedAxisX( initialOffsetX: (fullWidth: Int) -> Int, targetOffsetX: (fullWidth: Int) -> Int, durationMillis: Int = MotionConstants.DefaultMotionDuration, ): ContentTransform = materialSharedAxisXIn( initialOffsetX = initialOffsetX, durationMillis = durationMillis, ) togetherWith materialSharedAxisXOut(targetOffsetX = targetOffsetX, durationMillis = durationMillis) /** [materialSharedAxisXIn] allows to switch a layout with shared X-axis enter transition. */ public fun materialSharedAxisXIn( initialOffsetX: (fullWidth: Int) -> Int, durationMillis: Int = MotionConstants.DefaultMotionDuration, ): EnterTransition = slideInHorizontally( animationSpec = tween(durationMillis = durationMillis, easing = FastOutSlowInEasing), initialOffsetX = initialOffsetX, ) + fadeIn( animationSpec = tween( durationMillis = durationMillis.ForIncoming, delayMillis = durationMillis.ForOutgoing, easing = LinearOutSlowInEasing, ) ) /** [materialSharedAxisXOut] allows to switch a layout with shared X-axis exit transition. */ public fun materialSharedAxisXOut( targetOffsetX: (fullWidth: Int) -> Int, durationMillis: Int = MotionConstants.DefaultMotionDuration, ): ExitTransition = slideOutHorizontally( animationSpec = tween(durationMillis = durationMillis, easing = FastOutSlowInEasing), targetOffsetX = targetOffsetX, ) + fadeOut( animationSpec = tween( durationMillis = durationMillis.ForOutgoing, delayMillis = 0, easing = FastOutLinearInEasing, ) ) /** [materialSharedAxisY] allows to switch a layout with shared Y-axis transition. */ public fun materialSharedAxisY( initialOffsetY: (fullWidth: Int) -> Int, targetOffsetY: (fullWidth: Int) -> Int, durationMillis: Int = MotionConstants.DefaultMotionDuration, ): ContentTransform = materialSharedAxisYIn( initialOffsetY = initialOffsetY, durationMillis = durationMillis, ) togetherWith materialSharedAxisYOut(targetOffsetY = targetOffsetY, durationMillis = durationMillis) /** [materialSharedAxisYIn] allows to switch a layout with shared Y-axis enter transition. */ public fun materialSharedAxisYIn( initialOffsetY: (fullWidth: Int) -> Int, durationMillis: Int = MotionConstants.DefaultMotionDuration, ): EnterTransition = slideInVertically( animationSpec = tween(durationMillis = durationMillis, easing = FastOutSlowInEasing), initialOffsetY = initialOffsetY, ) + fadeIn( animationSpec = tween( durationMillis = durationMillis.ForIncoming, delayMillis = durationMillis.ForOutgoing, easing = LinearOutSlowInEasing, ) ) /** [materialSharedAxisYOut] allows to switch a layout with shared Y-axis exit transition. */ public fun materialSharedAxisYOut( targetOffsetY: (fullWidth: Int) -> Int, durationMillis: Int = MotionConstants.DefaultMotionDuration, ): ExitTransition = slideOutVertically( animationSpec = tween(durationMillis = durationMillis, easing = FastOutSlowInEasing), targetOffsetY = targetOffsetY, ) + fadeOut( animationSpec = tween( durationMillis = durationMillis.ForOutgoing, delayMillis = 0, easing = FastOutLinearInEasing, ) ) /** * [materialSharedAxisZ] allows to switch a layout with shared Z-axis transition. * * @param forward whether the direction of the animation is forward. * @param durationMillis the duration of transition. */ public fun materialSharedAxisZ( forward: Boolean, durationMillis: Int = MotionConstants.DefaultMotionDuration, ): ContentTransform = materialSharedAxisZIn(forward = forward, durationMillis = durationMillis) togetherWith materialSharedAxisZOut(forward = forward, durationMillis = durationMillis) /** * [materialSharedAxisZIn] allows to switch a layout with shared Z-axis enter transition. * * @param forward whether the direction of the animation is forward. * @param durationMillis the duration of the enter transition. */ public fun materialSharedAxisZIn( forward: Boolean, durationMillis: Int = MotionConstants.DefaultMotionDuration, ): EnterTransition = fadeIn( animationSpec = tween( durationMillis = durationMillis.ForIncoming, delayMillis = durationMillis.ForOutgoing, easing = LinearOutSlowInEasing, ) ) + scaleIn( animationSpec = tween(durationMillis = durationMillis, easing = FastOutSlowInEasing), initialScale = if (forward) 0.8f else 1.1f, ) /** * [materialSharedAxisZOut] allows to switch a layout with shared Z-axis exit transition. * * @param forward whether the direction of the animation is forward. * @param durationMillis the duration of the exit transition. */ public fun materialSharedAxisZOut( forward: Boolean, durationMillis: Int = MotionConstants.DefaultMotionDuration, ): ExitTransition = fadeOut( animationSpec = tween( durationMillis = durationMillis.ForOutgoing, delayMillis = 0, easing = FastOutLinearInEasing, ) ) + scaleOut( animationSpec = tween(durationMillis = durationMillis, easing = FastOutSlowInEasing), targetScale = if (forward) 1.1f else 0.8f, ) ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/common/motion/MotionConstants.kt ================================================ package com.junkfood.seal.ui.common.motion /* * Copyright 2021 SOUP * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp public object MotionConstants { public const val DefaultMotionDuration: Int = 300 public const val DefaultFadeInDuration: Int = 150 public const val DefaultFadeOutDuration: Int = 75 public val DefaultSlideDistance: Dp = 30.dp } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/component/ActionSheetItems.kt ================================================ package com.junkfood.seal.ui.component import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp @Composable fun ActionSheetPrimaryButton( modifier: Modifier = Modifier, containerColor: Color, contentColor: Color, outlineColor: Color = Color.Unspecified, imageVector: ImageVector, text: String, onClick: () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier .widthIn(min = 88.dp) .clip(MaterialTheme.shapes.large) .clickable( onClick = onClick, indication = null, interactionSource = interactionSource, ) .padding(8.dp), ) { Box( modifier = Modifier.width(80.dp) .height(64.dp) .clip(CircleShape) .then( if (outlineColor.isSpecified) Modifier.border( width = 1.dp, outlineColor.takeOrElse { Color.Transparent }, shape = CircleShape, ) else Modifier ) .background(containerColor) .indication(interactionSource, indication = LocalIndication.current) ) { Icon( imageVector, null, modifier = Modifier.size(24.dp).align(Alignment.Center), tint = contentColor, ) } Spacer(Modifier.height(8.dp)) ProvideTextStyle(LocalTextStyle.current.merge(MaterialTheme.typography.labelMedium)) { Text(text) } } } @Composable fun ActionSheetItem( modifier: Modifier = Modifier, leadingIcon: @Composable (() -> Unit)? = null, text: @Composable (ColumnScope.() -> Unit), trailingIcon: @Composable (() -> Unit)? = null, onLongClickLabel: String? = null, onLongClick: (() -> Unit)? = null, onClickLabel: String? = null, onClick: (() -> Unit)? = null, ) { Row( modifier = modifier .fillMaxWidth() .then( if (onClick == null) { Modifier } else if (onLongClick == null) { Modifier.clickable(onClickLabel = onClickLabel, onClick = onClick) } else { Modifier.combinedClickable( onClick = onClick, onLongClick = onLongClick, onClickLabel = onClickLabel, onLongClickLabel = onLongClickLabel, ) } ) .padding(vertical = 16.dp, horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically, ) { leadingIcon?.invoke() if (leadingIcon != null) Spacer(Modifier.width(20.dp)) ProvideTextStyle(LocalTextStyle.current.merge(MaterialTheme.typography.titleSmall)) { Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp), ) { text.invoke(this) } } trailingIcon?.invoke() } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/component/Buttons.kt ================================================ package com.junkfood.seal.ui.component import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.OpenInNew import androidx.compose.material3.Button import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.junkfood.seal.R import com.junkfood.seal.ui.page.settings.general.ytdlpReference @Composable fun OutlinedButtonWithIcon( modifier: Modifier = Modifier, onClick: () -> Unit, icon: ImageVector, text: String, contentColor: Color = MaterialTheme.colorScheme.primary, ) { OutlinedButton( modifier = modifier, onClick = onClick, contentPadding = ButtonDefaults.ButtonWithIconContentPadding, colors = ButtonDefaults.outlinedButtonColors(contentColor = contentColor), ) { Icon( modifier = Modifier.size(ButtonDefaults.IconSize), imageVector = icon, contentDescription = null, ) Text(modifier = Modifier.padding(start = 8.dp), text = text) } } @Composable fun TextButtonWithIcon( modifier: Modifier = Modifier, icon: ImageVector, text: String, contentColor: Color = MaterialTheme.colorScheme.primary, onClick: () -> Unit, ) { TextButton( modifier = modifier, onClick = onClick, contentPadding = ButtonDefaults.ButtonWithIconContentPadding, colors = ButtonDefaults.textButtonColors(contentColor = contentColor), ) { Row(verticalAlignment = Alignment.CenterVertically) { Icon(modifier = Modifier.size(18.dp), imageVector = icon, contentDescription = null) Text(modifier = Modifier.padding(start = 8.dp), text = text) } } } @Composable fun FilledTonalButtonWithIcon( modifier: Modifier = Modifier, onClick: () -> Unit, icon: ImageVector, text: String, colors: ButtonColors = ButtonDefaults.filledTonalButtonColors(), ) { FilledTonalButton( modifier = modifier, onClick = onClick, contentPadding = ButtonDefaults.ButtonWithIconContentPadding, colors = colors, ) { Icon(modifier = Modifier.size(18.dp), imageVector = icon, contentDescription = null) Text(modifier = Modifier.padding(start = 8.dp), text = text) } } @Composable fun FilledButtonWithIcon( modifier: Modifier = Modifier, icon: ImageVector, text: String, enabled: Boolean = true, onClick: () -> Unit, ) { Button( modifier = modifier, onClick = onClick, contentPadding = ButtonDefaults.ButtonWithIconContentPadding, enabled = enabled, ) { Icon(modifier = Modifier.size(18.dp), imageVector = icon, contentDescription = null) Text(modifier = Modifier.padding(start = 6.dp), text = text) } } @Composable fun ConfirmButton( text: String = stringResource(R.string.confirm), enabled: Boolean = true, onClick: () -> Unit, ) { TextButton(onClick = onClick, enabled = enabled) { Text(text) } } @Composable fun DismissButton(text: String = stringResource(R.string.dismiss), onClick: () -> Unit) { TextButton(onClick = onClick) { Text(text) } } @Composable fun OutlinedDismissButton(text: String = stringResource(R.string.dismiss), onClick: () -> Unit) { OutlinedButton(onClick = onClick) { Text(text) } } @Composable fun FilledConfirmButton( text: String = stringResource(R.string.confirm), enabled: Boolean = true, onClick: () -> Unit, ) { Button(onClick = onClick, enabled = enabled) { Text(text) } } @Composable fun LinkButton( modifier: Modifier = Modifier, text: String = stringResource(R.string.yt_dlp_docs), icon: ImageVector = Icons.Outlined.OpenInNew, link: String = ytdlpReference, ) { val uriHandler = LocalUriHandler.current TextButtonWithIcon( modifier = modifier, onClick = { uriHandler.openUri(link) }, icon = icon, text = text, ) } @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun LongTapTextButton( onClick: () -> Unit, onClickLabel: String, onLongClick: () -> Unit, onLongClickLabel: String, modifier: Modifier = Modifier, shape: Shape = ButtonDefaults.shape, border: BorderStroke? = null, contentPadding: PaddingValues = ButtonDefaults.ButtonWithIconContentPadding, content: @Composable RowScope.() -> Unit, ) { val contentColor = MaterialTheme.colorScheme.primary Row( modifier = modifier .clip(shape) .combinedClickable( onClick = onClick, onClickLabel = onClickLabel, onLongClick = onLongClick, onLongClickLabel = onLongClickLabel, ) ) { CompositionLocalProvider(LocalContentColor provides contentColor) { ProvideTextStyle(value = MaterialTheme.typography.labelLarge) { Row( Modifier.defaultMinSize( minWidth = ButtonDefaults.MinWidth, minHeight = ButtonDefaults.MinHeight, ) .padding(contentPadding), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, content = content, ) } } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/component/Chips.kt ================================================ package com.junkfood.seal.ui.component import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Clear import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.ElevatedAssistChip import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.InputChipDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SelectableChipColors import androidx.compose.material3.SelectableChipElevation import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.junkfood.seal.R import com.junkfood.seal.ui.theme.FixedAccentColors @OptIn(ExperimentalMaterial3Api::class) @Composable fun ButtonChip( modifier: Modifier = Modifier, label: String, enabled: Boolean = true, icon: ImageVector? = null, iconColor: Color = MaterialTheme.colorScheme.primary, iconDescription: String? = null, onClick: () -> Unit, ) { ElevatedAssistChip( modifier = modifier.padding(horizontal = 4.dp), onClick = onClick, label = { Text(label) }, colors = AssistChipDefaults.elevatedAssistChipColors(leadingIconContentColor = iconColor), enabled = enabled, leadingIcon = { if (icon != null) Icon( imageVector = icon, contentDescription = iconDescription, modifier = Modifier.size(AssistChipDefaults.IconSize), ) }, ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun FlatButtonChip( modifier: Modifier = Modifier, icon: ImageVector, label: String, iconColor: Color = MaterialTheme.colorScheme.primary, labelColor: Color = MaterialTheme.colorScheme.onSurface, onClick: () -> Unit, ) { AssistChip( modifier = modifier.padding(horizontal = 4.dp), colors = AssistChipDefaults.assistChipColors( containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, labelColor = labelColor, leadingIconContentColor = iconColor, ), border = null, onClick = onClick, leadingIcon = { Icon( imageVector = icon, contentDescription = null, Modifier.size(AssistChipDefaults.IconSize), ) }, label = { Text(text = label) }, ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun OutlinedButtonChip( modifier: Modifier = Modifier, icon: ImageVector? = null, label: String, shape: Shape = AssistChipDefaults.shape, onClick: () -> Unit, ) { AssistChip( modifier = modifier, onClick = onClick, leadingIcon = { icon?.let { Icon( imageVector = it, contentDescription = null, Modifier.size(AssistChipDefaults.IconSize), ) } }, label = { Text(text = label) }, shape = shape, ) } @Composable fun SingleChoiceChip( modifier: Modifier = Modifier, selected: Boolean, enabled: Boolean = true, label: String, leadingIcon: ImageVector = Icons.Outlined.Check, onClick: () -> Unit, ) { FilterChip( modifier = modifier.padding(horizontal = 4.dp), selected = selected, onClick = onClick, enabled = enabled, shape = MaterialTheme.shapes.large, label = { Text(text = label) }, leadingIcon = { Row { AnimatedVisibility(visible = selected) { Icon( imageVector = leadingIcon, contentDescription = null, modifier = Modifier.size(FilterChipDefaults.IconSize), ) } } }, ) } @Composable fun VideoFilterChip( modifier: Modifier = Modifier, selected: Boolean, enabled: Boolean = true, onClick: () -> Unit, label: String, leadingIcon: ImageVector? = null, ) { FilterChip( modifier = modifier.padding(horizontal = 4.dp), selected = selected, enabled = enabled, onClick = onClick, label = { Text(text = label) }, leadingIcon = { leadingIcon?.let { Icon(imageVector = it, contentDescription = null) } }, ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun ShortcutChip( modifier: Modifier = Modifier, text: String, onClick: (() -> Unit)? = null, onRemove: (() -> Unit)? = null, ) { AssistChip( modifier = modifier.padding(horizontal = 4.dp), onClick = { onClick?.invoke() }, label = { Text(text = text, maxLines = 1, overflow = TextOverflow.Ellipsis) }, trailingIcon = { onRemove?.let { IconButton( onClick = onRemove, modifier = Modifier.size(InputChipDefaults.IconSize), ) { Icon( imageVector = Icons.Outlined.Clear, contentDescription = stringResource(id = R.string.remove), tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(InputChipDefaults.IconSize), ) } } }, ) } @Composable fun SingleSelectChip( selected: Boolean, onClick: () -> Unit, label: @Composable () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, trailingIcon: @Composable (() -> Unit)? = null, colors: SelectableChipColors = FilterChipDefaults.filterChipColors( selectedContainerColor = FixedAccentColors.secondaryFixed, selectedLabelColor = FixedAccentColors.onSecondaryFixed, selectedLeadingIconColor = FixedAccentColors.onSecondaryFixed, selectedTrailingIconColor = FixedAccentColors.onSecondaryFixed, containerColor = MaterialTheme.colorScheme.surfaceContainer, iconColor = MaterialTheme.colorScheme.onSurface, labelColor = MaterialTheme.colorScheme.onSurface, ), border: BorderStroke? = null, elevation: SelectableChipElevation? = FilterChipDefaults.filterChipElevation(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { FilterChip( selected = selected, onClick = onClick, label = label, modifier = modifier, enabled = enabled, leadingIcon = {}, trailingIcon = trailingIcon, shape = MaterialTheme.shapes.extraLarge, colors = colors, elevation = elevation, border = border, interactionSource = interactionSource, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/component/CommonComponents.kt ================================================ package com.junkfood.seal.ui.component import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable fun NavigationBarSpacer(modifier: Modifier = Modifier) { Spacer( modifier = modifier.height( with(WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()) { if (this.value > 30f) this else 0f.dp } ) ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/component/DialogItems.kt ================================================ package com.junkfood.seal.ui.component import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.toggleable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.junkfood.seal.R @Composable fun DialogSingleChoiceItem( modifier: Modifier = Modifier, text: String, selected: Boolean, onClick: () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } Row( modifier = modifier .selectable( selected = selected, enabled = true, onClick = onClick, indication = LocalIndication.current, interactionSource = interactionSource, ) .fillMaxWidth() .padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start, ) { RadioButton( modifier = Modifier.minimumInteractiveComponentSize().clearAndSetSemantics {}, selected = selected, onClick = null, interactionSource = interactionSource, ) Text( text = text, style = LocalTextStyle.current.copy(fontSize = 16.sp), modifier = Modifier.padding(vertical = 8.dp), ) } } @Preview @Composable fun SingleChoiceItemPreview() { Surface { Column(modifier = Modifier.width(400.dp)) { DialogSingleChoiceItemVariant( title = "Better compatibility", desc = stringResource(R.string.prefer_compatibility_desc), selected = false, ) {} DialogSingleChoiceItemVariant( title = "Better quality", desc = stringResource(R.string.prefer_quality_desc), selected = true, ) {} DialogSingleChoiceItemVariant( title = "Better quality", desc = stringResource(R.string.prefer_quality_desc), selected = true, action = { Spacer(modifier = Modifier.width(8.dp)) VerticalDivider(modifier = Modifier.height(32.dp)) IconButton(onClick = {}) { Icon(Icons.Outlined.Settings, null) } }, ) {} DialogSingleChoiceItem(text = "Preview", selected = true) {} } } } @Composable fun DialogSingleChoiceItemVariant( modifier: Modifier = Modifier, title: String, desc: String?, selected: Boolean, action: (@Composable () -> Unit)? = null, onClick: () -> Unit, ) { Row( modifier = modifier .selectable(selected = selected, enabled = true, onClick = onClick) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start, ) { Spacer(modifier = Modifier.width(8.dp)) Column(modifier = Modifier.weight(1f).padding(vertical = 12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { RadioButton( modifier = Modifier.padding(start = 12.dp, end = 12.dp).clearAndSetSemantics {}, selected = selected, onClick = null, ) Text(text = title, style = MaterialTheme.typography.titleMedium) } desc?.let { Text( text = it, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 4.dp, start = 48.dp), ) } } action?.invoke() ?: Spacer(modifier = Modifier.width(16.dp)) } } @Composable fun CheckBoxItem( modifier: Modifier = Modifier, text: String, checked: Boolean, onValueChange: (Boolean) -> Unit, ) { Row( modifier = Modifier.padding(top = 12.dp) .fillMaxWidth() .toggleable(value = checked, enabled = true, onValueChange = onValueChange) ) { Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { Checkbox( modifier = Modifier.clearAndSetSemantics {}, checked = checked, onCheckedChange = onValueChange, ) Text(modifier = Modifier, text = text, style = MaterialTheme.typography.bodyMedium) } } } @Composable fun DialogSwitchItem( modifier: Modifier = Modifier, text: String, value: Boolean, onValueChange: (Boolean) -> Unit, ) { Row( modifier = modifier .fillMaxWidth() .toggleable(value = value, onValueChange = onValueChange) .padding(horizontal = 24.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = text, style = MaterialTheme.typography.labelLarge, modifier = Modifier.weight(1f), ) val thumbContent: (@Composable () -> Unit)? = rememberThumbContent(isChecked = value) val density = LocalDensity.current CompositionLocalProvider( LocalDensity provides Density(density.density * 0.8f, density.fontScale) ) { Switch( checked = value, onCheckedChange = onValueChange, modifier = Modifier.clearAndSetSemantics {}, thumbContent = thumbContent, ) } } } @Preview @Composable private fun SwitchItemPrev() { var value by remember { mutableStateOf(false) } Surface { DialogSwitchItem(text = "Use cookies", value = value) { value = it } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/component/Dialogs.kt ================================================ package com.junkfood.seal.ui.component import android.content.res.Configuration import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.HelpOutline import androidx.compose.material.icons.outlined.SignalCellularConnectedNoInternet4Bar import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import com.junkfood.seal.R import com.junkfood.seal.ui.theme.FixedAccentColors import com.junkfood.seal.ui.theme.SealTheme private val DialogVerticalPadding = PaddingValues(vertical = 24.dp) private val IconPadding = PaddingValues(bottom = 16.dp) private val DialogHorizontalPadding = PaddingValues(horizontal = 24.dp) private val TitlePadding = PaddingValues(bottom = 16.dp) private val TextPadding = PaddingValues(bottom = 24.dp) private val ButtonsMainAxisSpacing = 8.dp private val ButtonsCrossAxisSpacing = 12.dp @Composable fun HelpDialog( text: String, onDismissRequest: () -> Unit = {}, dismissButton: @Composable (() -> Unit)? = null, confirmButton: @Composable () -> Unit = { ConfirmButton(text = stringResource(id = R.string.got_it)) { onDismissRequest() } }, ) { AlertDialog( onDismissRequest = onDismissRequest, title = { Text(text = stringResource(id = R.string.how_does_it_work)) }, icon = { Icon(Icons.Outlined.HelpOutline, null) }, text = { Text(text = text) }, confirmButton = confirmButton, dismissButton = dismissButton, ) } @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun SealDialog( modifier: Modifier = Modifier, onDismissRequest: () -> Unit, confirmButton: @Composable (() -> Unit)?, dismissButton: @Composable (() -> Unit)? = null, icon: @Composable (() -> Unit)? = null, title: @Composable (() -> Unit)? = null, text: @Composable (() -> Unit)? = null, shape: Shape = AlertDialogDefaults.shape, containerColor: Color = AlertDialogDefaults.containerColor, iconContentColor: Color = AlertDialogDefaults.iconContentColor, titleContentColor: Color = AlertDialogDefaults.titleContentColor, textContentColor: Color = AlertDialogDefaults.textContentColor, tonalElevation: Dp = AlertDialogDefaults.TonalElevation, properties: DialogProperties = DialogProperties(), ) { BasicAlertDialog( onDismissRequest = onDismissRequest, modifier = modifier, properties = properties, ) { Surface( modifier = modifier, shape = shape, color = containerColor, tonalElevation = tonalElevation, ) { Column(modifier = Modifier.padding(DialogVerticalPadding)) { icon?.let { CompositionLocalProvider(LocalContentColor provides iconContentColor) { Box( Modifier.padding(IconPadding) .padding(DialogHorizontalPadding) .align(Alignment.CenterHorizontally) ) { icon() } } } title?.let { CompositionLocalProvider(LocalContentColor provides titleContentColor) { val textStyle = MaterialTheme.typography.headlineSmall.copy( textAlign = TextAlign.Center ) ProvideTextStyle(textStyle) { Box( // Align the title to the center when an icon is present. Modifier.padding(TitlePadding) .padding(DialogHorizontalPadding) .align( if (icon == null) { Alignment.Start } else { Alignment.CenterHorizontally } ) ) { title() } } } } text?.let { CompositionLocalProvider(LocalContentColor provides textContentColor) { val textStyle = MaterialTheme.typography.bodyMedium ProvideTextStyle(textStyle) { Box( Modifier.weight(weight = 1f, fill = false) .padding(TextPadding) .align(Alignment.Start) ) { text() } } } } Box(modifier = Modifier.align(Alignment.End).padding(DialogHorizontalPadding)) { val textStyle = MaterialTheme.typography.labelLarge ProvideTextStyle(value = textStyle) { FlowRow( horizontalArrangement = Arrangement.spacedBy(ButtonsMainAxisSpacing), verticalArrangement = Arrangement.spacedBy(ButtonsCrossAxisSpacing), ) { dismissButton?.invoke() confirmButton?.invoke() } } } } } } } @Composable fun SealDialogButtonVariant( modifier: Modifier = Modifier, shape: Shape = MiddleButtonShape, text: String, onClick: () -> Unit, ) { Box() { Surface( modifier = modifier.clickable(onClick = onClick).fillMaxWidth().height(48.dp), color = FixedAccentColors.secondaryFixed, shape = shape, ) {} Text( text = text, style = MaterialTheme.typography.labelLarge, color = FixedAccentColors.onSecondaryFixed, modifier = Modifier.align(Alignment.Center), ) } } @Preview(name = "dark", uiMode = Configuration.UI_MODE_NIGHT_YES) @Preview(name = "light", uiMode = Configuration.UI_MODE_NIGHT_NO) @Composable private fun ButtonVariantPreview() { SealTheme { SealDialogVariant( onDismissRequest = {}, modifier = Modifier, icon = { Icon( imageVector = Icons.Outlined.SignalCellularConnectedNoInternet4Bar, contentDescription = null, ) }, title = { Text( text = "Download with cellular network?", style = MaterialTheme.typography.titleLarge, textAlign = TextAlign.Center, ) }, buttons = { SealDialogButtonVariant( text = stringResource(R.string.allow_always), shape = TopButtonShape, ) {} SealDialogButtonVariant( text = stringResource(id = R.string.allow_once), shape = MiddleButtonShape, ) {} SealDialogButtonVariant( text = stringResource(R.string.dont_allow), shape = BottomButtonShape, ) {} }, ) } } val TopButtonShape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp, bottomStart = 4.dp, bottomEnd = 4.dp) val MiddleButtonShape = RoundedCornerShape(4.dp) val BottomButtonShape = RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp, bottomStart = 12.dp, bottomEnd = 12.dp) @OptIn(ExperimentalMaterial3Api::class) @Composable fun SealDialogVariant( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, buttons: @Composable (() -> Unit)? = null, icon: @Composable (() -> Unit)? = null, title: @Composable (() -> Unit)? = null, text: @Composable (() -> Unit)? = null, shape: Shape = AlertDialogDefaults.shape, containerColor: Color = MaterialTheme.colorScheme.surfaceContainer, iconContentColor: Color = AlertDialogDefaults.iconContentColor, titleContentColor: Color = AlertDialogDefaults.titleContentColor, textContentColor: Color = AlertDialogDefaults.textContentColor, tonalElevation: Dp = AlertDialogDefaults.TonalElevation, properties: DialogProperties = DialogProperties(), ) { AlertDialog(onDismissRequest = onDismissRequest, modifier = modifier, properties = properties) { Surface( modifier = modifier, shape = shape, color = containerColor, tonalElevation = tonalElevation, ) { Column(modifier = Modifier.padding(DialogVerticalPadding)) { icon?.let { CompositionLocalProvider(LocalContentColor provides iconContentColor) { Box( Modifier.padding(IconPadding) .padding(DialogHorizontalPadding) .align(Alignment.CenterHorizontally) ) { icon() } } } title?.let { CompositionLocalProvider(LocalContentColor provides titleContentColor) { val textStyle = MaterialTheme.typography.headlineSmall ProvideTextStyle(textStyle.copy(textAlign = TextAlign.Center)) { Box( // Align the title to the center when an icon is present. Modifier.padding(TitlePadding) .padding(DialogHorizontalPadding) .align( if (icon == null) { Alignment.Start } else { Alignment.CenterHorizontally } ) ) { title() } } } } text?.let { CompositionLocalProvider(LocalContentColor provides textContentColor) { val textStyle = MaterialTheme.typography.bodyMedium ProvideTextStyle(textStyle) { Box( Modifier.weight(weight = 1f, fill = false) .padding(TextPadding) .align(Alignment.Start) ) { text() } } } } Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(DialogHorizontalPadding), ) { buttons?.invoke() } } } } } @Composable fun DialogSubtitle( modifier: Modifier = Modifier, text: String, color: Color = MaterialTheme.colorScheme.primary, ) { Text( text = text, modifier = modifier.fillMaxWidth().padding(horizontal = 24.dp).padding(top = 16.dp, bottom = 4.dp), color = color, style = MaterialTheme.typography.labelLarge, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/component/DownloadQueueItem.kt ================================================ package com.junkfood.seal.ui.component import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.outlined.Cancel import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material.icons.outlined.RestartAlt import androidx.compose.material.icons.outlined.UnfoldMore import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.junkfood.seal.R import com.junkfood.seal.ui.common.AsyncImageImpl import com.junkfood.seal.ui.common.LocalDarkTheme import com.junkfood.seal.ui.common.LocalWindowWidthState import com.junkfood.seal.ui.theme.PreviewThemeLight import com.junkfood.seal.ui.theme.harmonizeWith import com.junkfood.seal.ui.theme.harmonizeWithPrimary import com.kyant.monet.LocalTonalPalettes import com.kyant.monet.TonalPalettes.Companion.toTonalPalettes import com.kyant.monet.dynamicColorScheme @Composable // @Preview fun PlaylistPreview() { var selected by remember { mutableStateOf(false) } Column() { PreviewThemeLight { PlaylistItem(selected = selected) { selected = !selected } } } } @Composable fun PlaylistItem( modifier: Modifier = Modifier, selected: Boolean = false, imageModel: Any = R.drawable.sample, title: String = "sample title ".repeat(5), author: String? = "author sample ".repeat(5), onClick: () -> Unit = {}, ) { Surface(modifier = Modifier.fillMaxWidth().selectable(selected) { onClick() }) { Row(modifier = modifier.fillMaxWidth().padding(vertical = 4.dp)) { Checkbox( modifier = Modifier.padding(start = 4.dp, end = 12.dp).align(Alignment.CenterVertically), checked = selected, onCheckedChange = null, ) Box( modifier = Modifier.padding(4.dp) .padding(end = 4.dp) .weight( if (LocalWindowWidthState.current == WindowWidthSizeClass.Compact) 2f else 1f ) ) { AsyncImageImpl( modifier = Modifier.clip(MaterialTheme.shapes.extraSmall) .aspectRatio(16f / 9f, matchHeightConstraintsFirst = true), model = imageModel, contentDescription = null, contentScale = ContentScale.Crop, ) } Column(modifier = Modifier.padding(vertical = 4.dp).weight(3f)) { Text( text = title, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis, ) author?.let { Text( modifier = Modifier.padding(top = 2.dp), text = author, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } } } } @Composable @Preview fun TaskItemPreview() { PreviewThemeLight { Surface { LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { item { CustomCommandTaskItem(status = TaskStatus.RUNNING) } item { CustomCommandTaskItem(status = TaskStatus.FINISHED) } item { CustomCommandTaskItem(status = TaskStatus.ERROR) } item { CustomCommandTaskItem(status = TaskStatus.CANCELED) } } } } } enum class TaskStatus { RUNNING, ERROR, CANCELED, FINISHED, } val GreenTonalPalettes = Color.Green.toTonalPalettes() @OptIn(ExperimentalMaterial3Api::class) @Composable fun CustomCommandTaskItem( modifier: Modifier = Modifier, status: TaskStatus = TaskStatus.ERROR, progress: Float = .85f, url: String = "https://www.example.com", templateName: String = "Template Example", progressText: String = "[sample] Extracting URL: https://www.example.com\n" + "[sample] sample: Downloading webpage\n" + "[sample] sample: Downloading android player API JSON\n" + "[info] Available automatic captions for sample:" + "[info] Available automatic captions for sample:", onCopyLog: () -> Unit = {}, onCopyError: () -> Unit = {}, onRestart: () -> Unit = {}, onShowLog: () -> Unit = {}, onCancel: () -> Unit = {}, ) { CompositionLocalProvider(LocalTonalPalettes provides GreenTonalPalettes) { val greenScheme = dynamicColorScheme(!LocalDarkTheme.current.isDarkTheme()) val accentColor = MaterialTheme.colorScheme.run { when (status) { TaskStatus.FINISHED -> greenScheme.primary TaskStatus.CANCELED -> onSurfaceVariant TaskStatus.RUNNING -> primary TaskStatus.ERROR -> error.harmonizeWithPrimary() } } val containerColor = MaterialTheme.colorScheme .run { /* when (status) { TaskStatus.FINISHED -> greenScheme.primaryContainer TaskStatus.CANCELED -> surfaceVariant.copy(alpha = alpha) TaskStatus.RUNNING -> tertiaryContainer.copy(alpha = alpha) TaskStatus.ERROR -> errorContainer.copy(alpha = alpha) }*/ surfaceContainerLow.harmonizeWith(other = accentColor) } .copy(alpha = 0.9f) val contentColor = MaterialTheme.colorScheme.run { // when (status) { // TaskStatus.FINISHED -> greenScheme.onPrimaryContainer // TaskStatus.CANCELED -> onSurfaceVariant // TaskStatus.RUNNING -> onTertiaryContainer // TaskStatus.ERROR -> onErrorContainer // } onSurfaceVariant.harmonizeWith(other = accentColor) } val labelText = stringResource( id = when (status) { TaskStatus.FINISHED -> R.string.status_completed TaskStatus.CANCELED -> R.string.status_canceled TaskStatus.RUNNING -> R.string.status_downloading TaskStatus.ERROR -> R.string.status_error } ) Surface(color = containerColor, shape = CardDefaults.shape) { Column(modifier = Modifier.padding(16.dp)) { Row( modifier = Modifier.semantics(mergeDescendants = true) {}, verticalAlignment = Alignment.CenterVertically, ) { when (status) { TaskStatus.FINISHED -> { Icon( modifier = Modifier.padding(8.dp).size(24.dp), imageVector = Icons.Filled.CheckCircle, tint = accentColor, contentDescription = stringResource(id = R.string.status_completed), ) } TaskStatus.CANCELED -> { Icon( modifier = Modifier.padding(8.dp).size(24.dp), imageVector = Icons.Filled.Cancel, tint = accentColor, contentDescription = stringResource(id = R.string.status_canceled), ) } TaskStatus.RUNNING -> { val animatedProgress by animateFloatAsState( targetValue = progress, animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, ) if (progress < 0) CircularProgressIndicator( modifier = Modifier.padding(8.dp).size(24.dp), strokeWidth = 5.dp, color = accentColor, ) else CircularProgressIndicator( modifier = Modifier.padding(8.dp).size(24.dp), strokeWidth = 5.dp, progress = animatedProgress, color = accentColor, ) } TaskStatus.ERROR -> { Icon( modifier = Modifier.padding(8.dp).size(24.dp), imageVector = Icons.Filled.Error, tint = accentColor, contentDescription = stringResource(id = R.string.status_error), ) } } Column(Modifier.padding(horizontal = 8.dp).weight(1f)) { Text( text = templateName, style = MaterialTheme.typography.titleSmall, color = contentColor, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( text = url, style = MaterialTheme.typography.bodyMedium, maxLines = 1, color = contentColor, overflow = TextOverflow.Ellipsis, ) } IconButton( modifier = Modifier.align(Alignment.Top).semantics(mergeDescendants = true) {}, onClick = { onShowLog() }, colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, contentColor = MaterialTheme.colorScheme.onSurfaceVariant, ), ) { Icon( imageVector = Icons.Outlined.UnfoldMore, contentDescription = stringResource(id = R.string.show_logs), ) } } Text( modifier = Modifier.padding(8.dp).padding(top = 4.dp), text = progressText, style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), color = if (status == TaskStatus.ERROR) MaterialTheme.colorScheme.error else contentColor, maxLines = 3, minLines = 3, overflow = TextOverflow.Ellipsis, ) Row(modifier = Modifier.horizontalScroll(rememberScrollState())) { FlatButtonChip( icon = Icons.Outlined.ContentCopy, label = stringResource(id = R.string.copy_log), ) { onCopyLog() } if (status == TaskStatus.ERROR) FlatButtonChip( icon = Icons.Outlined.ErrorOutline, label = stringResource(id = R.string.copy_error_report), iconColor = MaterialTheme.colorScheme.error, ) { onCopyError() } if (status == TaskStatus.RUNNING) FlatButtonChip( icon = Icons.Outlined.Cancel, label = stringResource(id = R.string.cancel), iconColor = contentColor, ) { onCancel() } if (status == TaskStatus.CANCELED || status == TaskStatus.ERROR) FlatButtonChip( icon = Icons.Outlined.RestartAlt, label = stringResource(id = R.string.restart), ) { onRestart() } } } } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/component/FormatItem.kt ================================================ package com.junkfood.seal.ui.component import android.content.res.Configuration import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyGridScope import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ContentCut import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.Image import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.VerticalSplit import androidx.compose.material.icons.rounded.Audiotrack import androidx.compose.material.icons.rounded.QuestionMark import androidx.compose.material.icons.rounded.Videocam import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.junkfood.seal.R import com.junkfood.seal.ui.theme.SealTheme import com.junkfood.seal.util.Format import com.junkfood.seal.util.VideoInfo import com.junkfood.seal.util.connectWithBlank import com.junkfood.seal.util.connectWithDelimiter import com.junkfood.seal.util.toBitrateText import com.junkfood.seal.util.toDurationText import com.junkfood.seal.util.toFileSizeText @Composable fun FormatVideoPreview( modifier: Modifier = Modifier, title: String, author: String, thumbnailUrl: String, duration: Int, isSplittingVideo: Boolean, isClippingVideo: Boolean, isClippingAvailable: Boolean = false, isSplitByChapterAvailable: Boolean = false, onRename: () -> Unit = {}, onOpenThumbnail: () -> Unit = {}, onClippingToggled: () -> Unit = {}, onSplittingToggled: () -> Unit = {}, ) { Box(modifier = Modifier.fillMaxWidth().wrapContentHeight(Alignment.Top, unbounded = false)) { Row(modifier = modifier.fillMaxWidth()) { Box(modifier = Modifier) { MediaImage( modifier = Modifier, imageModel = thumbnailUrl, isAudio = false, contentDescription = stringResource(id = R.string.thumbnail), ) Surface( modifier = Modifier.padding(2.dp).align(Alignment.BottomEnd), color = Color.Black.copy(alpha = 0.68f), shape = MaterialTheme.shapes.extraSmall, ) { val durationText = duration.toDurationText() Text( modifier = Modifier.padding(horizontal = 4.dp), text = durationText, style = MaterialTheme.typography.labelSmall, color = Color.White, ) } } Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.Top) { Text( modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), text = title, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis, ) if (author != "playlist" && author != "null") Text( modifier = Modifier.padding(horizontal = 12.dp).padding(top = 3.dp), text = author, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } var expanded by remember { mutableStateOf(false) } Box(modifier = Modifier.align(Alignment.BottomEnd)) { IconButton(onClick = { expanded = true }, modifier = Modifier.size(36.dp)) { Icon( imageVector = Icons.Outlined.MoreVert, stringResource(id = R.string.show_more_actions), modifier = Modifier.size(18.dp), ) } DropdownMenu( modifier = Modifier.align(Alignment.BottomEnd), expanded = expanded, onDismissRequest = { expanded = false }, scrollState = rememberScrollState(), ) { DropdownMenuItem( leadingIcon = { Icon(imageVector = Icons.Outlined.Edit, null) }, text = { Text(text = stringResource(id = R.string.rename)) }, onClick = { onRename() expanded = false }, ) DropdownMenuItem( leadingIcon = { Icon(imageVector = Icons.Outlined.Image, null) }, text = { Text(text = stringResource(id = R.string.thumbnail)) }, onClick = { onOpenThumbnail() expanded = false }, ) if (isClippingAvailable && !isClippingVideo && !isSplittingVideo) { DropdownMenuItem( leadingIcon = { Icon(Icons.Outlined.ContentCut, null) }, text = { Text(text = stringResource(id = R.string.clip_video)) }, onClick = { onClippingToggled() expanded = false }, ) } if (isSplitByChapterAvailable && !isClippingVideo && !isSplittingVideo) { DropdownMenuItem( leadingIcon = { Icon(Icons.Outlined.VerticalSplit, null) }, text = { Text(text = stringResource(id = R.string.split_video)) }, onClick = { onSplittingToggled() expanded = false }, ) } } } } } @Composable @Preview fun VideoInfoPreview() { SealTheme { Surface { Column { FormatVideoPreview( title = stringResource(id = R.string.video_title_sample_text), author = stringResource(id = R.string.video_creator_sample_text), thumbnailUrl = "", duration = 7890, isSplittingVideo = false, isClippingVideo = false, isSplitByChapterAvailable = true, isClippingAvailable = true, ) } } } } @Composable fun SuggestedFormatItem( modifier: Modifier = Modifier, videoInfo: VideoInfo, selected: Boolean = false, onClick: () -> Unit = {}, ) { val requestedFormats = videoInfo.requestedFormats ?: videoInfo.requestedDownloads?.map { it.toFormat() } ?: emptyList() val duration = videoInfo.duration ?: 0.0 val containsVideo = requestedFormats.any { it.containsVideo() } val containsAudio = requestedFormats.any { it.containsVideo() } val title = requestedFormats.joinToString(separator = " + ") { it.format.toString() } val totalFileSize = requestedFormats.fold(initial = 0.0) { acc: Double, format: Format -> acc + (format.fileSize ?: format.fileSizeApprox ?: (duration * (format.tbr ?: 0.0) * 125)) // kbps -> bytes 1000/8 } val fileSizeText = totalFileSize.toFileSizeText() val totalTbr = requestedFormats.fold(initial = 0.0) { acc: Double, format: Format -> acc + (format.tbr ?: 0.0) } val tbrText = totalTbr.toBitrateText() val firstLineText = connectWithDelimiter(fileSizeText, tbrText, delimiter = " ") val vcodecText = videoInfo.vcodec?.substringBefore(delimiter = ".") ?: "" val acodecText = videoInfo.acodec?.substringBefore(delimiter = ".") ?: "" val codecText = connectWithBlank(vcodecText, acodecText).run { if (isNotBlank()) "($this)" else this } val secondLineText = connectWithDelimiter(videoInfo.ext, codecText, delimiter = " ").uppercase() FormatItem( modifier = modifier, title = title, containsAudio = containsAudio, containsVideo = containsVideo, firstLineText = firstLineText, secondLineText = secondLineText, selected = selected, onClick = onClick, ) } @Composable fun FormatItem( modifier: Modifier = Modifier, formatInfo: Format, duration: Double, selected: Boolean = false, outlineColor: Color = MaterialTheme.colorScheme.primary, containerColor: Color = MaterialTheme.colorScheme.primaryContainer, onLongClick: (() -> Unit)? = null, onClick: () -> Unit = {}, ) { with(formatInfo) { val vcodecText = vcodec?.substringBefore(delimiter = ".") ?: "" val acodecText = acodec?.substringBefore(delimiter = ".") ?: "" val codec = connectWithBlank(vcodecText, acodecText).run { if (isNotBlank()) "($this)" else this } val tbrText = when { tbr == null -> "" // i don't care tbr < 1024f -> "%.1f Kbps".format(tbr) else -> "%.2f Mbps".format(tbr / 1024f) } val fileSize = fileSize ?: fileSizeApprox ?: (tbr?.times(duration * 125)) val fileSizeText = fileSize.toFileSizeText() val firstLineText = connectWithDelimiter(fileSizeText, tbrText, delimiter = " ") val secondLineText = connectWithDelimiter(ext, codec, delimiter = " ").uppercase() FormatItem( modifier = modifier, title = format.toString(), containsAudio = formatInfo.containsAudio(), containsVideo = formatInfo.containsVideo(), firstLineText = firstLineText, secondLineText = secondLineText, outlineColor = outlineColor, containerColor = containerColor, selected = selected, onLongClick = onLongClick, onClick = onClick, ) } } @OptIn(ExperimentalFoundationApi::class) @Composable fun FormatItem( modifier: Modifier = Modifier, title: String = "247 - 1280x720 (720p)", containsAudio: Boolean = false, containsVideo: Boolean = false, firstLineText: String, secondLineText: String, selected: Boolean = false, outlineColor: Color = MaterialTheme.colorScheme.primary, containerColor: Color = MaterialTheme.colorScheme.primaryContainer, onLongClick: (() -> Unit)? = null, onClick: () -> Unit = {}, ) { val animatedTitleColor by animateColorAsState( if (selected) outlineColor else MaterialTheme.colorScheme.onSurface, animationSpec = tween(100), label = "", ) val animatedContainerColor by animateColorAsState( if (selected) containerColor else MaterialTheme.colorScheme.surface, animationSpec = tween(100), label = "", ) val animatedOutlineColor by animateColorAsState( targetValue = if (selected) outlineColor else MaterialTheme.colorScheme.outlineVariant, animationSpec = tween(100), label = "", ) Box( modifier = modifier .clip(MaterialTheme.shapes.medium) .selectable(selected = selected) { onClick() } .combinedClickable( onClick = { onClick() }, onLongClick = onLongClick, onLongClickLabel = stringResource(R.string.copy_link), ) .border( width = 1.dp, color = animatedOutlineColor, shape = MaterialTheme.shapes.medium, ) .background(animatedContainerColor) ) { Column(Modifier.padding(12.dp), horizontalAlignment = Alignment.Start) { Text( text = title, style = MaterialTheme.typography.titleSmall, minLines = 2, maxLines = 2, color = animatedTitleColor, overflow = TextOverflow.Clip, ) Text( text = firstLineText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(top = 6.dp), color = MaterialTheme.colorScheme.onSurface, maxLines = 2, ) Text( text = secondLineText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(top = 2.dp), color = MaterialTheme.colorScheme.onSurface, maxLines = 1, ) } Row(modifier = Modifier.padding(bottom = 6.dp, end = 6.dp).align(Alignment.BottomEnd)) { if (containsVideo) Icon( imageVector = Icons.Rounded.Videocam, tint = outlineColor, contentDescription = stringResource(id = R.string.video), modifier = Modifier.size(16.dp), ) if (containsAudio) Icon( imageVector = Icons.Rounded.Audiotrack, tint = outlineColor, contentDescription = stringResource(id = R.string.audio), modifier = Modifier.size(16.dp), ) if (!containsVideo && !containsAudio) { Icon( imageVector = Icons.Rounded.QuestionMark, tint = outlineColor, contentDescription = stringResource(id = R.string.unknown), modifier = Modifier.size(16.dp), ) } } } } @Composable @Preview( name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL, ) @Preview(name = "Light") fun PreviewFormat() { MaterialTheme( colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() ) { var selected by remember { mutableStateOf(-1) } Surface { Column() { // FormatSubtitle(text = stringResource(R.string.video_only)) LazyVerticalGrid( columns = GridCells.Adaptive(150.dp), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { FormatPreviewContent(selected) { selected = it } } } } } } fun LazyGridScope.FormatPreviewContent(selected: Int = 0, onClick: (Int) -> Unit = {}) { item(span = { GridItemSpan(maxLineSpan) }) { FormatSubtitle( text = "Suggested", modifier = Modifier.padding(top = 12.dp, bottom = 4.dp).padding(horizontal = 12.dp), ) } item(span = { GridItemSpan(maxLineSpan) }) { FormatItem( selected = selected == 1, containsAudio = true, containsVideo = true, firstLineText = "? MB + 16.00 MB, (? + 200) Kbps", secondLineText = "MKV (Unknown + OPUS)", ) { onClick(1) } } item(span = { GridItemSpan(maxLineSpan) }) { FormatSubtitle( text = stringResource(R.string.audio), color = MaterialTheme.colorScheme.tertiary, modifier = Modifier.padding(top = 12.dp, bottom = 4.dp).padding(horizontal = 12.dp), ) } for (i in 0..1) { item { FormatItem( selected = selected == i, outlineColor = MaterialTheme.colorScheme.tertiary, containerColor = MaterialTheme.colorScheme.tertiaryContainer, containsVideo = false, containsAudio = true, firstLineText = "", secondLineText = "OPUS (OPUS)", ) { onClick(i) } } } item { FormatItem( selected = selected == 2, outlineColor = MaterialTheme.colorScheme.tertiary, containerColor = MaterialTheme.colorScheme.tertiaryContainer, containsVideo = false, containsAudio = true, firstLineText = "", secondLineText = "Unknown (Unknown)", ) { onClick(2) } } item(span = { GridItemSpan(maxLineSpan) }) { FormatSubtitle( text = stringResource(R.string.video_only), modifier = Modifier.padding(top = 12.dp, bottom = 4.dp).padding(horizontal = 12.dp), ) } for (i in 0..2) { item { FormatItem( selected = selected == i, containsVideo = true, containsAudio = false, firstLineText = "69.00MB 745.7Kbps", secondLineText = "MP4 (AVC1)", ) { onClick(i) } } } item(span = { GridItemSpan(maxLineSpan) }) { FormatSubtitle( text = stringResource(R.string.video), color = MaterialTheme.colorScheme.secondary, modifier = Modifier.padding(top = 12.dp, bottom = 4.dp).padding(horizontal = 12.dp), ) } for (i in 0..3) { item { FormatItem( selected = selected == i, outlineColor = MaterialTheme.colorScheme.secondary, containerColor = MaterialTheme.colorScheme.secondaryContainer, containsVideo = true, containsAudio = true, firstLineText = "", secondLineText = "", ) { onClick(i) } } } } @Composable fun FormatSubtitle( modifier: Modifier = Modifier, text: String, color: Color = MaterialTheme.colorScheme.primary, ) { Text( text = text, modifier = modifier, color = color, style = MaterialTheme.typography.titleSmall, ) } @Preview @Composable fun FormatItemPreview() { FormatItem(formatInfo = Format(), duration = 20.0) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/component/IconButtons.kt ================================================ package com.junkfood.seal.ui.component import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Cancel import androidx.compose.material.icons.outlined.ContentPaste import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.junkfood.seal.R import com.junkfood.seal.ui.common.HapticFeedback.slightHapticFeedback @Composable fun PasteFromClipBoardButton(onPaste: (String) -> Unit = {}) { val clipboardManager = LocalClipboardManager.current PasteButton(onClick = { clipboardManager.getText()?.let { onPaste(it.toString()) } }) } @Composable fun PasteButton(onClick: () -> Unit = {}) { IconButton(onClick = onClick) { Icon(Icons.Outlined.ContentPaste, stringResource(R.string.paste)) } } @Composable fun AddButton(onClick: () -> Unit, enabled: Boolean = true) { IconButton(onClick = onClick, enabled = enabled) { Icon(imageVector = Icons.Outlined.Add, contentDescription = stringResource(R.string.add)) } } @Composable fun ClearButton(onClick: () -> Unit) { IconButton(onClick = onClick) { Icon( modifier = Modifier.size(24.dp), imageVector = Icons.Outlined.Cancel, contentDescription = stringResource(id = R.string.clear), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @Composable fun BackButton(onClick: () -> Unit) { val view = LocalView.current IconButton( modifier = Modifier, onClick = { onClick() view.slightHapticFeedback() }, ) { Icon( imageVector = Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.back), ) } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/component/ModalBottomSheetM2.kt ================================================ package com.junkfood.seal.ui.component import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ModalBottomSheetDefaults import androidx.compose.material.ModalBottomSheetState import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import kotlinx.coroutines.launch @Composable fun rememberSheetState( showSheet: Boolean, onVisibilityChange: (isVisible: Boolean) -> Unit, ): ModalBottomSheetState { val state = rememberModalBottomSheetState( skipHalfExpanded = true, initialValue = ModalBottomSheetValue.Hidden, ) LaunchedEffect(showSheet) { if (showSheet && state.targetValue == ModalBottomSheetValue.Hidden) { state.show() } else if (!showSheet && state.targetValue == ModalBottomSheetValue.Expanded) { state.hide() } } LaunchedEffect(state.targetValue) { when (state.targetValue) { ModalBottomSheetValue.Hidden -> onVisibilityChange(false) ModalBottomSheetValue.Expanded -> onVisibilityChange(true) else -> {} } } return state } @Preview @Composable private fun SheetTest() { var showSheet by remember { mutableStateOf(true) } val sheetState = rememberSheetState(showSheet = showSheet) { showSheet = it } val scope = rememberCoroutineScope() Surface { Column { Text("wtf") Text("showSheet = $showSheet") Button(onClick = { showSheet = true }) { Text("show sheet!") } Button(onClick = { scope.launch { sheetState.show() } }) { Text("sheetState.hide()") } } } SealModalBottomSheetM2(sheetState = sheetState) { Column { Button(onClick = { scope.launch { sheetState.hide() } }) { Text("sheetState.hide()") } Button(onClick = { showSheet = false }) { Text("showSheet = false") } } } } @Composable fun SealModalBottomSheetM2( modifier: Modifier = Modifier, sheetState: ModalBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden), contentPadding: PaddingValues = PaddingValues(horizontal = 28.dp), sheetGesturesEnabled: Boolean = true, sheetContent: @Composable ColumnScope.() -> Unit = {}, ) { androidx.compose.material.ModalBottomSheetLayout( modifier = modifier, sheetShape = RoundedCornerShape( topStart = 28.0.dp, topEnd = 28.0.dp, bottomEnd = 0.0.dp, bottomStart = 0.0.dp, ), sheetState = sheetState, sheetBackgroundColor = MaterialTheme.colorScheme.surfaceContainer, sheetElevation = if (sheetState.isVisible) ModalBottomSheetDefaults.Elevation else 0.dp, sheetGesturesEnabled = sheetGesturesEnabled, sheetContent = { Column { Surface(color = MaterialTheme.colorScheme.surfaceContainer) { Box(modifier = Modifier.padding(contentPadding)) { Row( modifier = modifier.padding(top = 8.dp).fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { Row( modifier = modifier .size(32.dp, 4.dp) .clip(CircleShape) .background( MaterialTheme.colorScheme.onSurfaceVariant.copy( alpha = 0.4f ) ) .zIndex(1f) ) {} } Column { Spacer(modifier = Modifier.height(40.dp)) sheetContent() Spacer(modifier = Modifier.height(28.dp)) } } } NavigationBarSpacer( modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainerHigh) .fillMaxWidth() ) } }, ) {} } @OptIn(ExperimentalMaterialApi::class) @Composable fun SealModalBottomSheetM2Variant( modifier: Modifier = Modifier, sheetState: ModalBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden), sheetGesturesEnabled: Boolean = true, sheetContent: @Composable ColumnScope.() -> Unit = {}, ) { androidx.compose.material.ModalBottomSheetLayout( modifier = modifier, sheetShape = RoundedCornerShape( topStart = 0.dp, topEnd = 0.dp, bottomEnd = 0.dp, bottomStart = 0.dp, ), sheetState = sheetState, sheetBackgroundColor = Color.Transparent, sheetElevation = if (sheetState.isVisible) ModalBottomSheetDefaults.Elevation else 0.dp, sheetGesturesEnabled = sheetGesturesEnabled, sheetContent = { Column { Box(modifier = Modifier) { Column { sheetContent() } } } NavigationBarSpacer( modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainerHigh) .fillMaxWidth() ) }, ) {} } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/component/ModalBottomSheetM3.kt ================================================ package com.junkfood.seal.ui.component import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheetDefaults import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp @OptIn(ExperimentalMaterial3Api::class) @Composable fun SealModalBottomSheet( modifier: Modifier = Modifier, sheetState: SheetState = with(LocalDensity.current) { SheetState( initialValue = SheetValue.Expanded, skipPartiallyExpanded = true, velocityThreshold = { 56.dp.toPx() }, positionalThreshold = { 125.dp.toPx() }, ) }, onDismissRequest: () -> Unit, contentPadding: PaddingValues = PaddingValues(horizontal = 28.dp), properties: ModalBottomSheetProperties = ModalBottomSheetDefaults.properties, content: @Composable ColumnScope.() -> Unit = {}, ) { ModalBottomSheet( modifier = modifier, onDismissRequest = onDismissRequest, sheetState = sheetState, properties = properties, ) { Column(modifier = Modifier.padding(paddingValues = contentPadding)) { content() Spacer(modifier = Modifier.height(28.dp)) } } } @Composable fun DrawerSheetSubtitle( modifier: Modifier = Modifier, text: String, color: Color = MaterialTheme.colorScheme.primary, ) { Text( text = text, modifier = modifier.fillMaxWidth().padding(start = 4.dp, top = 16.dp, bottom = 8.dp), color = color, style = MaterialTheme.typography.labelLarge, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/component/PreferenceItems.kt ================================================ package com.junkfood.seal.ui.component import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.toggleable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.TipsAndUpdates import androidx.compose.material.icons.outlined.ToggleOn import androidx.compose.material.icons.outlined.Translate import androidx.compose.material.icons.outlined.Update import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.junkfood.seal.R import com.junkfood.seal.ui.theme.FixedAccentColors import com.junkfood.seal.ui.theme.SealTheme import com.junkfood.seal.ui.theme.applyOpacity import com.junkfood.seal.ui.theme.harmonizeWithPrimary import com.kyant.monet.LocalTonalPalettes import com.kyant.monet.TonalPalettes.Companion.toTonalPalettes private const val horizontal = 8 private const val vertical = 12 private val PreferenceTitleVariant: TextStyle @Composable get() = MaterialTheme.typography.titleLarge.copy(fontSize = 20.sp) private val PreferenceTitle @Composable get() = MaterialTheme.typography.titleMedium @OptIn(ExperimentalFoundationApi::class) @Composable fun PreferenceItem( title: String, description: String? = null, icon: Any? = null, enabled: Boolean = true, onLongClickLabel: String? = null, onLongClick: (() -> Unit)? = null, onClickLabel: String? = null, leadingIcon: (@Composable () -> Unit)? = null, trailingIcon: (@Composable () -> Unit)? = null, onClick: () -> Unit = {}, ) { Surface( modifier = Modifier.combinedClickable( onClick = onClick, onClickLabel = onClickLabel, enabled = enabled, onLongClickLabel = onLongClickLabel, onLongClick = onLongClick, ) ) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal.dp, vertical.dp), verticalAlignment = Alignment.CenterVertically, ) { leadingIcon?.invoke() when (icon) { is ImageVector -> { Icon( imageVector = icon, contentDescription = null, modifier = Modifier.padding(start = 8.dp, end = 16.dp).size(24.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(enabled), ) } is Painter -> { Icon( painter = icon, contentDescription = null, modifier = Modifier.padding(start = 8.dp, end = 16.dp).size(24.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(enabled), ) } } Column( modifier = Modifier.weight(1f) .padding( horizontal = if (icon == null && leadingIcon == null) 8.dp else 0.dp ) .padding(end = 8.dp) ) { PreferenceItemTitle(text = title, enabled = enabled) if (!description.isNullOrEmpty()) PreferenceItemDescription(text = description, enabled = enabled) } trailingIcon?.let { VerticalDivider( modifier = Modifier.height(32.dp) .padding(horizontal = 8.dp) .align(Alignment.CenterVertically), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), thickness = 1.dp, ) trailingIcon.invoke() } } } } @Composable @Preview fun PreferenceItemPreview() { SealTheme { Surface { Column { PreferenceSubtitle(text = "Preview") PreferenceItem(title = "title", description = "description") PreferenceItem( title = "title", description = "description", icon = Icons.Outlined.Update, ) PreferenceItemVariant( title = "title", description = "description", icon = Icons.Outlined.Update, ) } } } } @OptIn(ExperimentalFoundationApi::class) @Composable fun PreferenceItemVariant( modifier: Modifier = Modifier, title: String, description: String? = null, icon: ImageVector? = null, enabled: Boolean = true, onLongClickLabel: String? = null, onLongClick: () -> Unit = {}, onClickLabel: String? = null, onClick: () -> Unit = {}, ) { Surface( modifier = Modifier.combinedClickable( enabled = enabled, onClick = onClick, onClickLabel = onClickLabel, onLongClick = onLongClick, onLongClickLabel = onLongClickLabel, ) ) { Row( modifier = modifier.fillMaxWidth().padding(12.dp, 16.dp), verticalAlignment = Alignment.CenterVertically, ) { icon?.let { Icon( imageVector = icon, contentDescription = null, modifier = Modifier.padding(start = 8.dp, end = 16.dp).size(24.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(enabled), ) } Column( modifier = Modifier.weight(1f) .padding(horizontal = if (icon == null) 12.dp else 0.dp) .padding(end = 8.dp) ) { PreferenceItemTitle(text = title, enabled = enabled) if (description != null) { PreferenceItemDescription(text = description, enabled = enabled) } } } } } @Composable fun PreferenceSingleChoiceItem( modifier: Modifier = Modifier, text: String, selected: Boolean, contentPadding: PaddingValues = PaddingValues(horizontal = 8.dp, vertical = 16.dp), onClick: () -> Unit, ) { Surface(modifier = Modifier.selectable(selected = selected, onClick = onClick)) { Row( modifier = modifier.fillMaxWidth().padding(contentPadding), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) { Text( text = text, maxLines = 1, style = PreferenceTitleVariant, color = MaterialTheme.colorScheme.onSurface, overflow = TextOverflow.Ellipsis, ) } RadioButton( selected = selected, onClick = onClick, modifier = Modifier.padding().clearAndSetSemantics {}, ) } } } @Composable internal fun PreferenceItemTitle( modifier: Modifier = Modifier, text: String, maxLines: Int = 2, style: TextStyle = PreferenceTitle, enabled: Boolean, color: Color = MaterialTheme.colorScheme.onBackground, overflow: TextOverflow = TextOverflow.Ellipsis, ) { Text( modifier = modifier, text = text, maxLines = maxLines, style = style, color = color.applyOpacity(enabled), overflow = overflow, ) } @Composable internal fun PreferenceItemDescription( modifier: Modifier = Modifier, text: String, maxLines: Int = Int.MAX_VALUE, style: TextStyle = MaterialTheme.typography.bodyMedium, enabled: Boolean, color: Color = MaterialTheme.colorScheme.onSurfaceVariant, overflow: TextOverflow = TextOverflow.Ellipsis, ) { Text( modifier = modifier, text = text, maxLines = maxLines, style = style, color = color.applyOpacity(enabled), overflow = overflow, ) } @Composable @Preview fun PreferenceSwitchPreview() { var b by remember { mutableStateOf(false) } SealTheme { PreferenceSwitch( title = "PreferenceSwitch", description = "Supporting text", icon = Icons.Outlined.ToggleOn, isChecked = b, ) { b = !b } } } @Composable @Preview fun PreferenceSwitchWithDividerPreview() { PreferenceSwitchWithDivider( title = "PreferenceSwitch", description = "Supporting text", icon = Icons.Outlined.ToggleOn, ) } @Composable fun rememberThumbContent( isChecked: Boolean, checkedIcon: ImageVector = Icons.Outlined.Check, ): (@Composable () -> Unit)? = remember(isChecked, checkedIcon) { if (isChecked) { { Icon( imageVector = checkedIcon, contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize), ) } } else { null } } @Composable fun PreferenceSwitchVariant( title: String, description: String? = null, icon: ImageVector? = null, enabled: Boolean = true, isChecked: Boolean = true, thumbContent: (@Composable () -> Unit)? = rememberThumbContent(isChecked = isChecked), onClick: (() -> Unit) = {}, ) { val interactionSource = remember { MutableInteractionSource() } Surface( modifier = Modifier.toggleable( value = isChecked, enabled = enabled, onValueChange = { onClick() }, indication = LocalIndication.current, interactionSource = interactionSource, ) ) { Row( modifier = Modifier.fillMaxWidth() .padding(horizontal.dp, vertical.dp) .padding(start = if (icon == null) 12.dp else 0.dp), verticalAlignment = Alignment.CenterVertically, ) { icon?.let { Icon( imageVector = icon, contentDescription = null, modifier = Modifier.padding(start = 8.dp, end = 16.dp).size(24.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(enabled), ) } Column(modifier = Modifier.weight(1f)) { PreferenceItemTitle(text = title, enabled = enabled, style = PreferenceTitleVariant) if (!description.isNullOrEmpty()) PreferenceItemDescription(text = description, enabled = enabled) } Switch( checked = isChecked, onCheckedChange = null, interactionSource = interactionSource, modifier = Modifier.padding(start = 20.dp, end = 6.dp), enabled = enabled, thumbContent = thumbContent, ) } } } @Composable fun PreferenceSwitch( title: String, description: String? = null, icon: ImageVector? = null, enabled: Boolean = true, isChecked: Boolean = true, thumbContent: (@Composable () -> Unit)? = rememberThumbContent(isChecked = isChecked), onClick: (() -> Unit) = {}, ) { val interactionSource = remember { MutableInteractionSource() } Surface( modifier = Modifier.toggleable( value = isChecked, enabled = enabled, onValueChange = { onClick() }, indication = LocalIndication.current, interactionSource = interactionSource, ) ) { Row( modifier = Modifier.fillMaxWidth() .padding(horizontal.dp, vertical.dp) .padding(start = if (icon == null) 12.dp else 0.dp), verticalAlignment = Alignment.CenterVertically, ) { icon?.let { Icon( imageVector = icon, contentDescription = null, modifier = Modifier.padding(start = 8.dp, end = 16.dp).size(24.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(enabled), ) } Column(modifier = Modifier.weight(1f)) { PreferenceItemTitle(text = title, enabled = enabled) if (!description.isNullOrEmpty()) PreferenceItemDescription(text = description, enabled = enabled) } Switch( checked = isChecked, onCheckedChange = null, interactionSource = interactionSource, modifier = Modifier.padding(start = 20.dp, end = 6.dp), enabled = enabled, thumbContent = thumbContent, ) } } } @Composable fun PreferenceSwitchWithDivider( title: String, description: String? = null, icon: ImageVector? = null, enabled: Boolean = true, isSwitchEnabled: Boolean = enabled, isChecked: Boolean = true, thumbContent: (@Composable () -> Unit)? = rememberThumbContent(isChecked = isChecked), onClick: (() -> Unit) = {}, onChecked: () -> Unit = {}, ) { Surface( modifier = Modifier.clickable( enabled = enabled, onClick = onClick, onClickLabel = stringResource(id = R.string.open_settings), ) ) { Row( modifier = Modifier.fillMaxWidth() .padding(horizontal.dp, vertical.dp) .height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically, ) { icon?.let { Icon( imageVector = icon, contentDescription = null, modifier = Modifier.padding(start = 8.dp, end = 16.dp).size(24.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(enabled), ) } Column(modifier = Modifier.weight(1f)) { PreferenceItemTitle(text = title, enabled = enabled) if (!description.isNullOrEmpty()) PreferenceItemDescription(text = description, enabled = enabled) } VerticalDivider( modifier = Modifier.height(32.dp) .padding(horizontal = 8.dp) .width(1f.dp) .align(Alignment.CenterVertically), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), ) Switch( checked = isChecked, onCheckedChange = { onChecked() }, modifier = Modifier.padding(horizontal = 6.dp).semantics { contentDescription = title }, enabled = isSwitchEnabled, thumbContent = thumbContent, ) } } } @Composable fun PreferencesCautionCard( title: String, description: String? = null, icon: ImageVector? = null, onClick: () -> Unit = {}, ) { Row( modifier = Modifier.fillMaxWidth() .padding(horizontal = 8.dp, vertical = 12.dp) .clip(MaterialTheme.shapes.extraLarge) .background(MaterialTheme.colorScheme.errorContainer.harmonizeWithPrimary()) .clickable { onClick() } .padding(horizontal = 12.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { icon?.let { Icon( imageVector = icon, contentDescription = null, modifier = Modifier.padding(start = 8.dp, end = 16.dp).size(24.dp), tint = MaterialTheme.colorScheme.error.harmonizeWithPrimary(), ) } Column( modifier = Modifier.weight(1f).padding(start = if (icon == null) 12.dp else 0.dp, end = 12.dp) ) { with(MaterialTheme) { Text( text = title, maxLines = 1, style = PreferenceTitleVariant, color = colorScheme.onErrorContainer.harmonizeWithPrimary(), ) if (description != null) Text( text = description, color = colorScheme.onErrorContainer.harmonizeWithPrimary(), maxLines = 2, overflow = TextOverflow.Ellipsis, style = typography.bodyMedium, ) } } } } @Preview @Composable fun PreferencesHintCardPreview() { CompositionLocalProvider(LocalTonalPalettes provides Color.Green.toTonalPalettes()) { PreferencesHintCard( title = "Explore new features", icon = Icons.Outlined.TipsAndUpdates, description = "Find out what's new in this version", containerColor = FixedAccentColors.primaryFixed, contentColor = FixedAccentColors.onPrimaryFixed, ) } } @Composable fun PreferencesHintCard( title: String = "Title ".repeat(2), description: String? = "Description text ".repeat(3), icon: ImageVector? = Icons.Outlined.Translate, containerColor: Color = FixedAccentColors.secondaryFixed, contentColor: Color = FixedAccentColors.onSecondaryFixed, onClick: () -> Unit = {}, ) { Row( modifier = Modifier.fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp) .clip(MaterialTheme.shapes.extraLarge) .background(containerColor) .clickable { onClick() } .padding(horizontal = 12.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { icon?.let { Icon( imageVector = icon, contentDescription = null, modifier = Modifier.padding(start = 8.dp, end = 16.dp).size(24.dp), tint = contentColor, ) } Column( modifier = Modifier.weight(1f).padding(start = if (icon == null) 12.dp else 0.dp, end = 12.dp) ) { with(MaterialTheme) { Text( text = title, maxLines = 1, style = PreferenceTitleVariant, color = contentColor, ) if (description != null) Text( text = description, color = contentColor, maxLines = 2, overflow = TextOverflow.Ellipsis, style = typography.bodyMedium, ) } } } } @Composable @Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(name = "Night", uiMode = Configuration.UI_MODE_NIGHT_YES) private fun PreferenceSwitchWithContainerPreview() { var isChecked by remember { mutableStateOf(false) } SealTheme { PreferenceSwitchWithContainer( title = "Title ".repeat(2), isChecked = isChecked, onClick = { isChecked = !isChecked }, icon = null, ) } } @Composable fun PreferenceSwitchWithContainer( title: String, icon: ImageVector? = null, isChecked: Boolean, thumbContent: @Composable (() -> Unit)? = rememberThumbContent(isChecked = isChecked), onClick: () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } Row( modifier = Modifier.fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp) .clip(MaterialTheme.shapes.extraLarge) .background(MaterialTheme.colorScheme.primaryContainer) .toggleable( value = isChecked, onValueChange = { onClick() }, interactionSource = interactionSource, indication = LocalIndication.current, ) .padding(horizontal = 16.dp, vertical = 20.dp), verticalAlignment = Alignment.CenterVertically, ) { icon?.let { Icon( imageVector = icon, contentDescription = null, modifier = Modifier.padding(start = 8.dp, end = 16.dp).size(24.dp), tint = MaterialTheme.colorScheme.onPrimaryContainer, ) } Column( modifier = Modifier.weight(1f).padding(start = if (icon == null) 12.dp else 0.dp, end = 12.dp) ) { Text( text = title, maxLines = 2, style = PreferenceTitleVariant, color = MaterialTheme.colorScheme.onPrimaryContainer, ) } Switch( checked = isChecked, interactionSource = interactionSource, onCheckedChange = null, modifier = Modifier.padding(start = 12.dp, end = 6.dp), thumbContent = thumbContent, ) } } @Composable fun CreditItem( title: String, license: String? = null, enabled: Boolean = true, onClick: () -> Unit = {}, ) { Surface(modifier = Modifier.clickable { onClick() }) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f).padding(horizontal = 10.dp)) { with(MaterialTheme) { Text( text = title, maxLines = 1, style = typography.titleMedium, color = colorScheme.onSurface.applyOpacity(enabled), ) license?.let { Text( text = it, color = colorScheme.onSurfaceVariant.applyOpacity(enabled), maxLines = 2, overflow = TextOverflow.Ellipsis, style = typography.bodyMedium, ) } } } } } } @OptIn(ExperimentalFoundationApi::class) @Composable @Preview fun TemplateItem( label: String = "", template: String? = null, selected: Boolean = false, isMultiSelectEnabled: Boolean = false, checked: Boolean = false, onClick: () -> Unit = {}, onSelect: () -> Unit = {}, onCheckedChange: (Boolean) -> Unit = {}, onLongClick: () -> Unit = {}, ) { Surface( modifier = Modifier.run { if (!isMultiSelectEnabled) then( this.combinedClickable( onClick = onClick, onClickLabel = stringResource(R.string.edit), onLongClick = onLongClick, onLongClickLabel = stringResource(R.string.multiselect_mode), ) ) else { then(this.toggleable(value = checked, onValueChange = onCheckedChange)) } } ) { Row( modifier = Modifier.fillMaxWidth().padding(16.dp, 16.dp), verticalAlignment = Alignment.CenterVertically, ) { AnimatedVisibility(visible = isMultiSelectEnabled) { Checkbox( modifier = Modifier.clearAndSetSemantics {}, checked = checked, onCheckedChange = onCheckedChange, ) } Column(modifier = Modifier.weight(1f).padding(horizontal = 10.dp)) { with(MaterialTheme) { Text( text = label, maxLines = 1, style = typography.titleMedium, color = colorScheme.onSurface, ) template?.let { Text( text = it, color = colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis, style = typography.bodyMedium, ) } } } AnimatedVisibility(!isMultiSelectEnabled) { Row { VerticalDivider( modifier = Modifier.height(32.dp) .padding(horizontal = 12.dp) .align(Alignment.CenterVertically), color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp, ) RadioButton( modifier = Modifier.semantics { contentDescription = label }, selected = selected, onClick = onSelect, ) } } } } } @Composable fun PreferenceSubtitle( text: String, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(start = 16.dp, top = 20.dp, bottom = 8.dp), color: Color = MaterialTheme.colorScheme.primary, ) { Text( text = text, modifier = modifier.padding(contentPadding), color = color, style = MaterialTheme.typography.labelLarge, ) } @Composable fun PreferenceInfo( modifier: Modifier = Modifier, text: String, icon: ImageVector = Icons.Outlined.Info, applyPaddings: Boolean = true, ) { Column( modifier = modifier.fillMaxWidth().run { if (applyPaddings) padding(horizontal = 16.dp, vertical = 16.dp) else this } ) { Icon(modifier = Modifier.padding(), imageVector = icon, contentDescription = null) Text( modifier = Modifier.padding(top = 16.dp), text = text, style = MaterialTheme.typography.bodyMedium, ) } } @Composable @Preview(showBackground = true) fun PreferenceInfoPreview() { PreferenceInfo(text = stringResource(id = R.string.custom_command_enabled_hint)) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/component/SearchBar.kt ================================================ package com.junkfood.seal.ui.component import android.view.HapticFeedbackConstants import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Clear import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.junkfood.seal.R import com.junkfood.seal.ui.theme.SealTheme @Composable fun SealSearchBar( modifier: Modifier = Modifier, text: String, placeholderText: String, onValueChange: (String) -> Unit, ) { val view = LocalView.current Surface( modifier = modifier.widthIn(360.dp, 720.dp), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceContainer, ) { Row(verticalAlignment = Alignment.CenterVertically) { Spacer(modifier = Modifier.width(16.dp)) Icon( imageVector = Icons.Outlined.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, ) SealAutoFocusTextField( value = text, onValueChange = onValueChange, placeholder = { Text(text = placeholderText) }, modifier = Modifier.weight(1f), contentDescription = stringResource(id = R.string.search), trailingIcon = { if (text.isNotEmpty()) { IconButton( onClick = { onValueChange("") view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) } ) { Icon( modifier = Modifier.size(24.dp), imageVector = Icons.Outlined.Clear, contentDescription = stringResource(id = R.string.clear), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } }, ) } } } @Preview @Composable private fun SearchBarPreview() { var text by remember { mutableStateOf("") } SealTheme { Surface { SealSearchBar( text = text, placeholderText = stringResource(R.string.search_in_downloads), ) { text = it } } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/component/SegementedButton.kt ================================================ package com.junkfood.seal.ui.component import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonColors import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRowScope import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape @Composable fun SingleChoiceSegmentedButtonRowScope.SingleChoiceSegmentedButton( selected: Boolean, onClick: () -> Unit, shape: Shape, modifier: Modifier = Modifier, enabled: Boolean = true, colors: SegmentedButtonColors = SegmentedButtonDefaults.colors(inactiveContainerColor = Color.Transparent), icon: @Composable () -> Unit = { SegmentedButtonDefaults.Icon(selected) }, label: @Composable () -> Unit, ) { SegmentedButton( selected = selected, onClick = onClick, shape = shape, modifier = modifier, enabled = enabled, colors = colors, icon = icon, label = label, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/component/SelectionGroup.kt ================================================ package com.junkfood.seal.ui.component import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.spring import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.junkfood.seal.ui.common.LocalFixedColorRoles import com.junkfood.seal.ui.theme.SealTheme @Composable fun SelectionGroupRow( modifier: Modifier = Modifier, content: @Composable SelectionGroupScope.() -> Unit, ) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier.selectableGroup()) { val scope = remember { SelectionGroupScope(this) } content.invoke(scope) } } class SelectionGroupScope(rowScope: RowScope) : RowScope by rowScope @Composable fun SelectionGroupScope.SelectionGroupItem( selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, shape: Shape = SelectionGroupDefaults.shape(selected), colors: SelectionGroupItemColors = SelectionGroupDefaults.colors(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 8.dp), content: @Composable RowScope.() -> Unit, ) { val containerColor by animateColorAsState(colors.containerColor(enabled, selected)) val contentColor by animateColorAsState(colors.contentColor(enabled, selected)) Surface( selected = selected, onClick = onClick, modifier = modifier, enabled = enabled, shape = shape, color = containerColor, contentColor = contentColor, interactionSource = interactionSource, ) { Row( modifier = Modifier.heightIn(min = 32.dp).widthIn(min = 56.dp).padding(contentPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { ProvideTextStyle(MaterialTheme.typography.labelLarge) { content.invoke(this) } } } } object SelectionGroupDefaults { @Composable fun shape(selected: Boolean): Shape { val animatedRoundedCorner by animateDpAsState( if (selected) 28.dp else 12.dp, label = "itemShape", animationSpec = spring(stiffness = Spring.StiffnessMediumLow), ) return RoundedCornerShape(animatedRoundedCorner) } @Composable fun colors( activeContainerColor: Color = Color.Unspecified, activeContentColor: Color = Color.Unspecified, inactiveContainerColor: Color = Color.Unspecified, inactiveContentColor: Color = Color.Unspecified, disabledActiveContainerColor: Color = Color.Unspecified, disabledActiveContentColor: Color = Color.Unspecified, disabledInactiveContainerColor: Color = Color.Unspecified, disabledInactiveContentColor: Color = Color.Unspecified, ): SelectionGroupItemColors { return defaultSelectionGroupItemColors.run { copy( activeContainerColor = activeContainerColor.takeOrElse { this.activeContainerColor }, activeContentColor = activeContentColor.takeOrElse { this.activeContentColor }, inactiveContainerColor = inactiveContainerColor.takeOrElse { this.inactiveContainerColor }, inactiveContentColor = inactiveContentColor.takeOrElse { this.inactiveContentColor }, disabledActiveContainerColor = disabledActiveContainerColor.takeOrElse { this.disabledActiveContainerColor }, disabledActiveContentColor = disabledActiveContentColor.takeOrElse { this.disabledActiveContentColor }, disabledInactiveContainerColor = disabledInactiveContainerColor.takeOrElse { this.disabledInactiveContainerColor }, disabledInactiveContentColor = disabledInactiveContentColor.takeOrElse { this.disabledInactiveContentColor }, ) } } private val defaultSelectionGroupItemColors: SelectionGroupItemColors @Composable @ReadOnlyComposable get() { val colorScheme = MaterialTheme.colorScheme val fixedColorRoles = LocalFixedColorRoles.current return SelectionGroupItemColors( activeContainerColor = fixedColorRoles.primaryFixed, activeContentColor = fixedColorRoles.onPrimaryFixed, inactiveContainerColor = colorScheme.surfaceContainer, inactiveContentColor = colorScheme.onSurface, disabledActiveContainerColor = colorScheme.onSurface.copy(alpha = 0.12f), disabledActiveContentColor = colorScheme.onSurface.copy(alpha = 0.38f), disabledInactiveContainerColor = colorScheme.onSurface.copy(alpha = 0.12f), disabledInactiveContentColor = colorScheme.onSurface.copy(alpha = 0.38f), ) } } @Immutable data class SelectionGroupItemColors( val activeContainerColor: Color, val activeContentColor: Color, val inactiveContainerColor: Color, val inactiveContentColor: Color, val disabledActiveContainerColor: Color, val disabledActiveContentColor: Color, val disabledInactiveContainerColor: Color, val disabledInactiveContentColor: Color, ) { @Stable internal fun contentColor(enabled: Boolean, checked: Boolean): Color { return when { enabled && checked -> activeContentColor enabled && !checked -> inactiveContentColor !enabled && checked -> disabledActiveContentColor else -> disabledInactiveContentColor } } @Stable internal fun containerColor(enabled: Boolean, active: Boolean): Color { return when { enabled && active -> activeContainerColor enabled && !active -> inactiveContainerColor !enabled && active -> disabledActiveContainerColor else -> disabledInactiveContainerColor } } } @Preview @Composable private fun Preview() { SealTheme { Surface { var selected by remember { mutableIntStateOf(0) } val itemSet = setOf("All", "Downloaded", "Canceled", "Finished") SelectionGroupRow(modifier = Modifier.horizontalScroll(rememberScrollState())) { itemSet.forEachIndexed { index, s -> SelectionGroupItem( selected = selected == index, onClick = { selected = index }, ) { Text(s, style = MaterialTheme.typography.labelLarge) } } } } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/component/SettingItem.kt ================================================ package com.junkfood.seal.ui.component import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @Composable fun SettingTitle(text: String) { Text( modifier = Modifier.padding(top = 32.dp).padding(horizontal = 20.dp, vertical = 16.dp), text = text, style = MaterialTheme.typography.displaySmall, ) } @Composable fun SettingItem(title: String, description: String, icon: ImageVector?, onClick: () -> Unit) { Surface(modifier = Modifier.clickable { onClick() }) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 20.dp), verticalAlignment = Alignment.CenterVertically, ) { icon?.let { Icon( imageVector = icon, contentDescription = null, modifier = Modifier.padding(end = 16.dp).size(24.dp), tint = MaterialTheme.colorScheme.primary, ) } Column( modifier = Modifier.weight(1f).padding(start = if (icon == null) 12.dp else 0.dp) ) { Text( text = title, maxLines = 1, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, overflow = TextOverflow.Ellipsis, ) Spacer(modifier = Modifier.height(2.dp)) Text( text = description, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, style = MaterialTheme.typography.bodyMedium, overflow = TextOverflow.Ellipsis, ) } } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/component/SponsorItem.kt ================================================ package com.junkfood.seal.ui.component import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.min import com.junkfood.seal.R import com.junkfood.seal.ui.common.AsyncImageImpl fun gitHubAvatar(userLogin: String): String = "https://github.com/${userLogin}.png" fun gitHubProfile(userLogin: String): String = "https://github.com/${userLogin}" @Composable fun SponsorItem( modifier: Modifier = Modifier, userName: String?, userLogin: String, avatarUrl: Any = gitHubAvatar(userLogin), profileUrl: String = gitHubProfile(userLogin), contentPadding: PaddingValues = PaddingValues(horizontal = 0.dp, vertical = 12.dp), onClick: () -> Unit = {}, ) { Column() { Column( modifier = modifier .fillMaxWidth() .clip(MaterialTheme.shapes.large) .clickable(onClick = onClick) .padding(4.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { AsyncImageImpl( modifier = Modifier.widthIn(min = 48.dp, max = 108.dp) .fillMaxWidth() .aspectRatio(1f, true) .clip(CircleShape), model = avatarUrl, contentDescription = null, contentScale = ContentScale.Crop, ) Column( modifier = Modifier.padding(contentPadding), horizontalAlignment = Alignment.CenterHorizontally, ) { userName?.let { Text( text = it, maxLines = 1, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, overflow = TextOverflow.Ellipsis, ) } Text( text = "@$userLogin", maxLines = 1, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, overflow = TextOverflow.Ellipsis, ) } } } } @Composable @Preview fun SponsorItemPreview() { SponsorItem( userName = "junkfood", userLogin = "JunkFood02", avatarUrl = R.drawable.sample1, profileUrl = "", ) {} } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/component/TextField.kt ================================================ package com.junkfood.seal.ui.component import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldColors import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay /** @param contentDescription Text label of the `TextField` for the accessibility service */ @Composable fun SealTextField( value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, readOnly: Boolean = false, textStyle: TextStyle = LocalTextStyle.current, contentDescription: String? = null, label: @Composable (() -> Unit)? = null, placeholder: @Composable (() -> Unit)? = null, leadingIcon: @Composable (() -> Unit)? = null, trailingIcon: @Composable (() -> Unit)? = null, prefix: @Composable (() -> Unit)? = null, suffix: @Composable (() -> Unit)? = null, supportingText: @Composable (() -> Unit)? = null, isError: Boolean = false, visualTransformation: VisualTransformation = VisualTransformation.None, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, singleLine: Boolean = false, maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, minLines: Int = 1, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, shape: Shape = TextFieldDefaults.shape, colors: TextFieldColors = TextFieldDefaults.colors( focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, disabledContainerColor = Color.Transparent, ), ) { TextField( value = value, onValueChange = onValueChange, modifier = modifier.then( Modifier.semantics { if (contentDescription != null) { this.contentDescription = contentDescription } } ), enabled = enabled, readOnly = readOnly, textStyle = textStyle, label = label, placeholder = placeholder, leadingIcon = leadingIcon, trailingIcon = trailingIcon, prefix = prefix, suffix = suffix, supportingText = supportingText, isError = isError, visualTransformation = visualTransformation, keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, singleLine = singleLine, maxLines = maxLines, minLines = minLines, interactionSource = interactionSource, shape = shape, colors = colors, ) } @Composable fun SealAutoFocusTextField( value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, readOnly: Boolean = false, textStyle: TextStyle = LocalTextStyle.current, contentDescription: String? = null, label: @Composable (() -> Unit)? = null, placeholder: @Composable (() -> Unit)? = null, leadingIcon: @Composable (() -> Unit)? = null, trailingIcon: @Composable (() -> Unit)? = null, prefix: @Composable (() -> Unit)? = null, suffix: @Composable (() -> Unit)? = null, supportingText: @Composable (() -> Unit)? = null, isError: Boolean = false, visualTransformation: VisualTransformation = VisualTransformation.None, singleLine: Boolean = false, maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, minLines: Int = 1, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, shape: Shape = TextFieldDefaults.shape, colors: TextFieldColors = TextFieldDefaults.colors( unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, disabledContainerColor = Color.Transparent, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, ), focusRequester: FocusRequester = remember { FocusRequester() }, ) { val focusManager = LocalFocusManager.current LaunchedEffect(Unit) { delay(200) focusRequester.requestFocus() } TextField( value = value, onValueChange = onValueChange, modifier = modifier .then( Modifier.semantics { if (contentDescription != null) { this.contentDescription = contentDescription } } ) .focusRequester(focusRequester = focusRequester), enabled = enabled, readOnly = readOnly, textStyle = textStyle, label = label, placeholder = placeholder, leadingIcon = leadingIcon, trailingIcon = trailingIcon, prefix = prefix, suffix = suffix, supportingText = supportingText, isError = isError, visualTransformation = visualTransformation, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), singleLine = singleLine, maxLines = maxLines, minLines = minLines, interactionSource = interactionSource, shape = shape, colors = colors, ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun SealTextField( value: TextFieldValue, onValueChange: (TextFieldValue) -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, readOnly: Boolean = false, textStyle: TextStyle = LocalTextStyle.current, label: @Composable (() -> Unit)? = null, placeholder: @Composable (() -> Unit)? = null, leadingIcon: @Composable (() -> Unit)? = null, trailingIcon: @Composable (() -> Unit)? = null, prefix: @Composable (() -> Unit)? = null, suffix: @Composable (() -> Unit)? = null, supportingText: @Composable (() -> Unit)? = null, isError: Boolean = false, visualTransformation: VisualTransformation = VisualTransformation.None, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, singleLine: Boolean = false, maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, minLines: Int = 1, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, shape: Shape = TextFieldDefaults.shape, colors: TextFieldColors = TextFieldDefaults.colors( focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, disabledContainerColor = Color.Transparent, ), ) { TextField( value, onValueChange, modifier, enabled, readOnly, textStyle, label, placeholder, leadingIcon, trailingIcon, prefix, suffix, supportingText, isError, visualTransformation, keyboardOptions, keyboardActions, singleLine, maxLines, minLines, interactionSource, shape, colors, ) } @Composable fun AdjacentLabel(modifier: Modifier = Modifier, text: String) { Text( text = text, modifier = modifier.padding(bottom = 12.dp, start = 4.dp), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/component/VideoCard.kt ================================================ package com.junkfood.seal.ui.component import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Cancel import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.junkfood.seal.R import com.junkfood.seal.ui.common.AsyncImageImpl import com.junkfood.seal.ui.theme.SealTheme import com.junkfood.seal.util.toDurationText import com.junkfood.seal.util.toFileSizeText @OptIn(ExperimentalMaterial3Api::class) @Composable fun VideoCard( modifier: Modifier = Modifier, title: String = stringResource(R.string.video_title_sample_text), author: String = stringResource(R.string.video_creator_sample_text), thumbnailUrl: Any = "", showCancelButton: Boolean = false, onCancel: () -> Unit = {}, onClick: () -> Unit = {}, progress: Float = 90f, fileSizeApprox: Double = 1024 * 1024 * 69.0, duration: Int = 359, isPreview: Boolean = false, ) { ElevatedCard( modifier = modifier.fillMaxWidth(), onClick = onClick, shape = MaterialTheme.shapes.small, ) { Column { Box(Modifier.fillMaxWidth()) { Crossfade(targetState = thumbnailUrl, label = "") { AsyncImageImpl( modifier = Modifier.padding() .fillMaxWidth() .aspectRatio(16f / 9f, matchHeightConstraintsFirst = true) .clip(MaterialTheme.shapes.small), model = it, contentDescription = null, contentScale = ContentScale.Crop, isPreview = isPreview, ) } Surface( modifier = Modifier.padding(4.dp).align(Alignment.BottomEnd), color = Color.Black.copy(alpha = 0.68f), shape = MaterialTheme.shapes.extraSmall, ) { val fileSizeText = fileSizeApprox.toFileSizeText() val durationText = duration.toDurationText() Text( modifier = Modifier.padding(horizontal = 4.dp), text = "$fileSizeText · $durationText", style = MaterialTheme.typography.labelSmall, color = Color.White, ) } Column(modifier = Modifier.align(Alignment.Center)) { AnimatedVisibility( visible = showCancelButton, enter = fadeIn(), exit = fadeOut(), ) { FilledTonalIconButton( onClick = onCancel, modifier = Modifier.size(56.dp), colors = IconButtonDefaults.filledTonalIconButtonColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHighest.copy( alpha = 0.68f ) ), ) { Icon( imageVector = Icons.Outlined.Cancel, contentDescription = stringResource(id = R.string.cancel), modifier = Modifier.size(32.dp), ) } } } } Column( modifier = Modifier.fillMaxWidth().padding(12.dp), horizontalAlignment = Alignment.Start, ) { Text( text = title, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis, ) Text( modifier = Modifier.padding(top = 3.dp), text = author, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis, ) } val progressAnimationValue by animateFloatAsState( targetValue = progress, animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, label = "", ) if (progress < 0f) LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) else LinearProgressIndicator( progress = { progressAnimationValue / 100f }, modifier = Modifier.fillMaxWidth(), drawStopIndicator = {}, ) } } } @Composable @Preview @Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES) fun VideoCardPreview() { SealTheme() { VideoCard(isPreview = true) } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/component/VideoListItem.kt ================================================ package com.junkfood.seal.ui.component import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.selection.selectable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.junkfood.seal.R import com.junkfood.seal.ui.common.AsyncImageImpl import com.junkfood.seal.util.toFileSizeText private const val AUDIO_REGEX = "\\.(mp3|aac|opus|m4a|flac|wav)" @Composable @Preview fun MediaListItemPreview() { MaterialTheme() { Surface() { MediaListItem( title = stringResource(id = R.string.video_title_sample_text), author = stringResource(id = (R.string.video_creator_sample_text)), videoFileSize = 5678 * 1024 * 1024L, ) } } } @OptIn(ExperimentalFoundationApi::class) @Composable fun MediaListItem( modifier: Modifier = Modifier, title: String = "", author: String = "", thumbnailUrl: String = "", videoPath: String = "", videoUrl: String = "", videoFileSize: Long = 0L, isSelectEnabled: () -> Boolean = { false }, isSelected: () -> Boolean = { false }, onSelect: () -> Unit = {}, onClick: () -> Unit = {}, onLongClick: () -> Unit = {}, onShowContextMenu: () -> Unit = {}, ) { val isAudio = videoPath.contains(Regex(AUDIO_REGEX)) val haptic = LocalHapticFeedback.current val context = LocalContext.current val isFileAvailable = videoFileSize != 0L val fileSizeText = videoFileSize.toFileSizeText() Box( modifier = with(modifier) { if (!isSelectEnabled()) combinedClickable( enabled = true, onClick = { onClick() }, onClickLabel = stringResource(R.string.open_file), onLongClick = { onLongClick() haptic.performHapticFeedback(HapticFeedbackType.LongPress) }, onLongClickLabel = stringResource(R.string.multiselect_mode), ) else selectable(selected = isSelected(), onClick = onSelect) } .fillMaxWidth() ) { Row(modifier = Modifier.fillMaxWidth().padding(12.dp)) { AnimatedVisibility( modifier = Modifier.align(Alignment.CenterVertically), visible = isSelectEnabled(), ) { Checkbox( modifier = Modifier.padding(start = 4.dp, end = 16.dp), checked = isSelected(), onCheckedChange = null, ) } MediaImage(modifier = Modifier, imageModel = thumbnailUrl, isAudio = isAudio) Column( modifier = Modifier.padding(horizontal = 12.dp).fillMaxWidth(), verticalArrangement = Arrangement.Top, ) { Text( text = title, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis, ) if (author != "null") Text( modifier = Modifier.padding(top = 3.dp), text = author, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( modifier = Modifier.padding(top = 3.dp), text = if (isFileAvailable) fileSizeText else stringResource(R.string.unavailable), style = MaterialTheme.typography.labelSmall, color = with(MaterialTheme.colorScheme) { if (isFileAvailable) onSurfaceVariant else error }, maxLines = 1, ) } } AnimatedVisibility( modifier = Modifier.align(Alignment.BottomEnd), visible = !isSelectEnabled(), enter = fadeIn(tween(100)), exit = fadeOut(tween(100)), ) { IconButton(modifier = Modifier.clearAndSetSemantics {}, onClick = onShowContextMenu) { Icon( modifier = Modifier.size(18.dp), imageVector = Icons.Outlined.MoreVert, contentDescription = stringResource(id = R.string.show_more_actions), ) } } } } @Composable fun MediaImage( modifier: Modifier = Modifier, imageModel: String, isAudio: Boolean = false, contentDescription: String? = null, ) { AsyncImageImpl( modifier = modifier .height(90.dp) .aspectRatio(if (!isAudio) 16f / 9f else 1f, matchHeightConstraintsFirst = true) .clip(MaterialTheme.shapes.extraSmall), model = imageModel, contentDescription = contentDescription, contentScale = ContentScale.Crop, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/AppEntry.kt ================================================ package com.junkfood.seal.ui.page import android.webkit.CookieManager import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.DrawerValue import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavGraphBuilder import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import androidx.navigation.navigation import com.junkfood.seal.App import com.junkfood.seal.R import com.junkfood.seal.ui.common.HapticFeedback.slightHapticFeedback import com.junkfood.seal.ui.common.LocalWindowWidthState import com.junkfood.seal.ui.common.Route import com.junkfood.seal.ui.common.animatedComposable import com.junkfood.seal.ui.common.animatedComposableVariant import com.junkfood.seal.ui.common.arg import com.junkfood.seal.ui.common.id import com.junkfood.seal.ui.common.slideInVerticallyComposable import com.junkfood.seal.ui.page.command.TaskListPage import com.junkfood.seal.ui.page.command.TaskLogPage import com.junkfood.seal.ui.page.downloadv2.configure.DownloadDialogViewModel import com.junkfood.seal.ui.page.downloadv2.DownloadPageV2 import com.junkfood.seal.ui.page.settings.SettingsPage import com.junkfood.seal.ui.page.settings.about.AboutPage import com.junkfood.seal.ui.page.settings.about.CreditsPage import com.junkfood.seal.ui.page.settings.about.SponsorsPage import com.junkfood.seal.ui.page.settings.about.UpdatePage import com.junkfood.seal.ui.page.settings.appearance.AppearancePreferences import com.junkfood.seal.ui.page.settings.appearance.DarkThemePreferences import com.junkfood.seal.ui.page.settings.appearance.LanguagePage import com.junkfood.seal.ui.page.settings.command.TemplateEditPage import com.junkfood.seal.ui.page.settings.command.TemplateListPage import com.junkfood.seal.ui.page.settings.directory.DownloadDirectoryPreferences import com.junkfood.seal.ui.page.settings.format.DownloadFormatPreferences import com.junkfood.seal.ui.page.settings.format.SubtitlePreference import com.junkfood.seal.ui.page.settings.general.GeneralDownloadPreferences import com.junkfood.seal.ui.page.settings.interaction.InteractionPreferencePage import com.junkfood.seal.ui.page.settings.network.CookieProfilePage import com.junkfood.seal.ui.page.settings.network.CookiesViewModel import com.junkfood.seal.ui.page.settings.network.NetworkPreferences import com.junkfood.seal.ui.page.settings.network.WebViewPage import com.junkfood.seal.ui.page.settings.troubleshooting.TroubleShootingPage import com.junkfood.seal.ui.page.videolist.VideoListPage import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel private const val TAG = "HomeEntry" private val TopDestinations = listOf(Route.HOME, Route.TASK_LIST, Route.SETTINGS_PAGE, Route.DOWNLOADS) @Composable fun AppEntry(dialogViewModel: DownloadDialogViewModel) { val navController = rememberNavController() val context = LocalContext.current val view = LocalView.current val windowWidth = LocalWindowWidthState.current val sheetState by dialogViewModel.sheetStateFlow.collectAsStateWithLifecycle() val cookiesViewModel: CookiesViewModel = koinViewModel() val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val versionReport = App.packageInfo.versionName.toString() val appName = stringResource(R.string.app_name) val scope = rememberCoroutineScope() val onNavigateBack: () -> Unit = { with(navController) { if (currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED) { popBackStack() } } } if (sheetState is DownloadDialogViewModel.SheetState.Configure) { if (navController.currentDestination?.route != Route.HOME) { navController.popBackStack(route = Route.HOME, inclusive = false, saveState = true) } } val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route var currentTopDestination by rememberSaveable { mutableStateOf(currentRoute) } LaunchedEffect(currentRoute) { if (currentRoute in TopDestinations) { currentTopDestination = currentRoute } } Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { NavigationDrawer( windowWidth = windowWidth, drawerState = drawerState, currentRoute = currentRoute, currentTopDestination = currentTopDestination, showQuickSettings = true, gesturesEnabled = currentRoute == Route.HOME, onDismissRequest = { drawerState.close() }, onNavigateToRoute = { if (currentRoute != it) { navController.navigate(it) { launchSingleTop = true popUpTo(route = Route.HOME) } } }, footer = { Text( appName + "\n" + versionReport + "\n" + currentRoute, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 12.dp), ) }, ) { NavHost( modifier = Modifier.align(Alignment.Center), navController = navController, startDestination = Route.HOME, ) { animatedComposable(Route.HOME) { DownloadPageV2( dialogViewModel = dialogViewModel, onMenuOpen = { view.slightHapticFeedback() scope.launch { drawerState.open() } }, ) } animatedComposable(Route.DOWNLOADS) { VideoListPage { onNavigateBack() } } animatedComposableVariant(Route.TASK_LIST) { TaskListPage( onNavigateBack = onNavigateBack, onNavigateToDetail = { navController.navigate(Route.TASK_LOG id it) }, ) } slideInVerticallyComposable( Route.TASK_LOG arg Route.TASK_HASHCODE, arguments = listOf(navArgument(Route.TASK_HASHCODE) { type = NavType.IntType }), ) { TaskLogPage( onNavigateBack = onNavigateBack, taskHashCode = it.arguments?.getInt(Route.TASK_HASHCODE) ?: -1, ) } settingsGraph( onNavigateBack = onNavigateBack, onNavigateTo = { route -> navController.navigate(route = route) { launchSingleTop = true } }, cookiesViewModel = cookiesViewModel, ) } AppUpdater() YtdlpUpdater() } } } fun NavGraphBuilder.settingsGraph( onNavigateBack: () -> Unit, onNavigateTo: (route: String) -> Unit, cookiesViewModel: CookiesViewModel, ) { navigation(startDestination = Route.SETTINGS_PAGE, route = Route.SETTINGS) { animatedComposable(Route.DOWNLOAD_DIRECTORY) { DownloadDirectoryPreferences(onNavigateBack) } animatedComposable(Route.SETTINGS_PAGE) { SettingsPage(onNavigateBack = onNavigateBack, onNavigateTo = onNavigateTo) } animatedComposable(Route.GENERAL_DOWNLOAD_PREFERENCES) { GeneralDownloadPreferences(onNavigateBack = { onNavigateBack() }) { onNavigateTo(Route.TEMPLATE) } } animatedComposable(Route.DOWNLOAD_FORMAT) { DownloadFormatPreferences(onNavigateBack = onNavigateBack) { onNavigateTo(Route.SUBTITLE_PREFERENCES) } } animatedComposable(Route.SUBTITLE_PREFERENCES) { SubtitlePreference { onNavigateBack() } } animatedComposable(Route.ABOUT) { AboutPage( onNavigateBack = onNavigateBack, onNavigateToCreditsPage = { onNavigateTo(Route.CREDITS) }, onNavigateToUpdatePage = { onNavigateTo(Route.AUTO_UPDATE) }, onNavigateToDonatePage = { onNavigateTo(Route.DONATE) }, ) } animatedComposable(Route.DONATE) { SponsorsPage(onNavigateBack) } animatedComposable(Route.CREDITS) { CreditsPage(onNavigateBack) } animatedComposable(Route.AUTO_UPDATE) { UpdatePage(onNavigateBack) } animatedComposable(Route.APPEARANCE) { AppearancePreferences(onNavigateBack = onNavigateBack, onNavigateTo = onNavigateTo) } animatedComposable(Route.INTERACTION) { InteractionPreferencePage(onBack = onNavigateBack) } animatedComposable(Route.LANGUAGES) { LanguagePage { onNavigateBack() } } animatedComposable(Route.DOWNLOAD_DIRECTORY) { DownloadDirectoryPreferences { onNavigateBack() } } animatedComposable(Route.TEMPLATE) { TemplateListPage(onNavigateBack = onNavigateBack) { onNavigateTo(Route.TEMPLATE_EDIT id it) } } animatedComposable( Route.TEMPLATE_EDIT arg Route.TEMPLATE_ID, arguments = listOf(navArgument(Route.TEMPLATE_ID) { type = NavType.IntType }), ) { TemplateEditPage(onNavigateBack, it.arguments?.getInt(Route.TEMPLATE_ID) ?: -1) } animatedComposable(Route.DARK_THEME) { DarkThemePreferences { onNavigateBack() } } animatedComposable(Route.NETWORK_PREFERENCES) { NetworkPreferences( navigateToCookieProfilePage = { onNavigateTo(Route.COOKIE_PROFILE) } ) { onNavigateBack() } } animatedComposable(Route.COOKIE_PROFILE) { CookieProfilePage( cookiesViewModel = cookiesViewModel, navigateToCookieGeneratorPage = { onNavigateTo(Route.COOKIE_GENERATOR_WEBVIEW) }, ) { onNavigateBack() } } animatedComposable(Route.COOKIE_GENERATOR_WEBVIEW) { WebViewPage(cookiesViewModel = cookiesViewModel) { onNavigateBack() CookieManager.getInstance().flush() } } animatedComposable(Route.TROUBLESHOOTING) { TroubleShootingPage(onNavigateTo = onNavigateTo, onBack = onNavigateBack) } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/AppUpdater.kt ================================================ package com.junkfood.seal.ui.page import android.Manifest import android.content.Intent import android.net.Uri import android.os.Build import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import com.junkfood.seal.R import com.junkfood.seal.util.PreferenceUtil import com.junkfood.seal.util.ToastUtil import com.junkfood.seal.util.UpdateUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @Composable fun AppUpdater() { val context = LocalContext.current var showUpdateDialog by rememberSaveable { mutableStateOf(false) } var currentDownloadStatus by remember { mutableStateOf(UpdateUtil.DownloadStatus.NotYet as UpdateUtil.DownloadStatus) } val scope = rememberCoroutineScope() var updateJob: Job? = null var release by remember { mutableStateOf(UpdateUtil.Release()) } val settings = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { UpdateUtil.installLatestApk() } val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { result -> if (result) { UpdateUtil.installLatestApk() } else { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (!context.packageManager.canRequestPackageInstalls()) settings.launch( Intent( Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"), ) ) else UpdateUtil.installLatestApk() } } } LaunchedEffect(Unit) { if ( !PreferenceUtil.isNetworkAvailableForDownload() || !PreferenceUtil.isAutoUpdateEnabled() ) return@LaunchedEffect withContext(Dispatchers.IO) { runCatching { UpdateUtil.checkForUpdate()?.let { release = it showUpdateDialog = true } } .onFailure { it.printStackTrace() } } } if (showUpdateDialog) { UpdateDialogImpl( onDismissRequest = { showUpdateDialog = false updateJob?.cancel() }, title = release.name.toString(), onConfirmUpdate = { updateJob = scope.launch(Dispatchers.IO) { runCatching { UpdateUtil.downloadApk(release = release).collect { downloadStatus -> currentDownloadStatus = downloadStatus if (downloadStatus is UpdateUtil.DownloadStatus.Finished) { launcher.launch( Manifest.permission.REQUEST_INSTALL_PACKAGES ) } } } .onFailure { it.printStackTrace() currentDownloadStatus = UpdateUtil.DownloadStatus.NotYet ToastUtil.makeToastSuspend( context.getString(R.string.app_update_failed) ) return@launch } } }, releaseNote = release.body.toString(), downloadStatus = currentDownloadStatus, ) } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/NavigationDrawer.kt ================================================ package com.junkfood.seal.ui.page import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Subscriptions import androidx.compose.material.icons.filled.Terminal import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.Menu import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Subscriptions import androidx.compose.material.icons.outlined.Terminal import androidx.compose.material.icons.outlined.VolunteerActivism import androidx.compose.material.icons.rounded.BugReport import androidx.compose.material.icons.rounded.Cookie import androidx.compose.material.icons.rounded.Folder import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.SettingsApplications import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerValue import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import com.junkfood.seal.R import com.junkfood.seal.ui.common.LocalWindowWidthState import com.junkfood.seal.ui.common.Route import com.junkfood.seal.ui.page.downloadv2.DownloadPageImplV2 import kotlinx.coroutines.launch @Composable fun NavigationDrawer( modifier: Modifier = Modifier, drawerState: DrawerState, windowWidth: WindowWidthSizeClass = LocalWindowWidthState.current, currentRoute: String? = null, currentTopDestination: String? = null, showQuickSettings: Boolean = true, onNavigateToRoute: (String) -> Unit, onDismissRequest: suspend () -> Unit, gesturesEnabled: Boolean = true, footer: @Composable (() -> Unit)? = null, content: @Composable () -> Unit, ) { val scope = rememberCoroutineScope() when (windowWidth) { WindowWidthSizeClass.Compact, WindowWidthSizeClass.Medium -> { ModalNavigationDrawer( gesturesEnabled = gesturesEnabled, drawerState = drawerState, drawerContent = { ModalDrawerSheet(drawerState = drawerState, modifier = modifier.width(360.dp)) { NavigationDrawerSheetContent( modifier = Modifier, currentRoute = currentRoute, showQuickSettings = showQuickSettings, onNavigateToRoute = onNavigateToRoute, onDismissRequest = onDismissRequest, footer = footer, ) } }, content = content, ) } WindowWidthSizeClass.Expanded -> { ModalNavigationDrawer( gesturesEnabled = drawerState.isOpen, drawerState = drawerState, drawerContent = { ModalDrawerSheet(drawerState = drawerState, modifier = modifier.width(360.dp)) { NavigationDrawerSheetContent( modifier = Modifier, currentRoute = currentRoute, showQuickSettings = showQuickSettings, onNavigateToRoute = onNavigateToRoute, onDismissRequest = onDismissRequest, footer = footer, ) } }, ) { Row { Surface( color = MaterialTheme.colorScheme.surfaceContainer, modifier = Modifier.zIndex(1f), ) { Column( verticalArrangement = Arrangement.Center, modifier = Modifier.fillMaxHeight().systemBarsPadding().width(92.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(Modifier.height(8.dp)) IconButton( onClick = { scope.launch { drawerState.open() } }, modifier = Modifier.align(Alignment.CenterHorizontally), ) { Icon(Icons.Outlined.Menu, null) } Spacer(Modifier.weight(1f)) NavigationRailContent( modifier = Modifier, currentTopDestination = currentTopDestination, onNavigateToRoute = onNavigateToRoute, ) Spacer(Modifier.weight(1f)) } } content() } } } } } @Composable fun NavigationDrawerSheetContent( modifier: Modifier = Modifier, currentRoute: String? = null, showQuickSettings: Boolean = true, onNavigateToRoute: (String) -> Unit, onDismissRequest: suspend () -> Unit, footer: @Composable (() -> Unit)? = null, ) { val scope = rememberCoroutineScope() Column( modifier = modifier .padding(horizontal = 12.dp) .fillMaxHeight() .verticalScroll(rememberScrollState()) .systemBarsPadding() ) { Spacer(Modifier.height(72.dp)) ProvideTextStyle(MaterialTheme.typography.labelLarge) { NavigationDrawerItem( label = { Text(stringResource(R.string.download_queue)) }, icon = { Icon(Icons.Filled.Download, null) }, onClick = { scope .launch { onDismissRequest() } .invokeOnCompletion { onNavigateToRoute(Route.HOME) } }, selected = currentRoute == Route.HOME, ) NavigationDrawerItem( label = { Text(stringResource(R.string.downloads_history)) }, icon = { Icon(Icons.Outlined.Subscriptions, null) }, onClick = { scope .launch { onDismissRequest() } .invokeOnCompletion { onNavigateToRoute(Route.DOWNLOADS) } }, selected = currentRoute == Route.DOWNLOADS, ) NavigationDrawerItem( label = { Text(stringResource(R.string.custom_command)) }, icon = { Icon(Icons.Outlined.Terminal, null) }, onClick = { scope .launch { onDismissRequest() } .invokeOnCompletion { onNavigateToRoute(Route.TASK_LIST) } }, selected = currentRoute == Route.TASK_LIST, ) NavigationDrawerItem( label = { Text(stringResource(R.string.settings)) }, icon = { Icon(Icons.Outlined.Settings, null) }, onClick = { scope .launch { onDismissRequest() } .invokeOnCompletion { onNavigateToRoute(Route.SETTINGS) } }, selected = currentRoute == Route.SETTINGS_PAGE, ) NavigationDrawerItem( label = { Text(stringResource(R.string.sponsor)) }, icon = { Icon(Icons.Outlined.VolunteerActivism, null) }, onClick = { scope .launch { onDismissRequest() } .invokeOnCompletion { onNavigateToRoute(Route.DONATE) } }, selected = currentRoute == Route.DONATE, ) if (showQuickSettings) { HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) Column( modifier = Modifier.padding(start = 16.dp).padding(top = 16.dp, bottom = 12.dp), verticalArrangement = Arrangement.Center, ) { Text( stringResource(R.string.settings), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.tertiary, modifier = Modifier, ) } NavigationDrawerItem( label = { Text(stringResource(R.string.general_settings)) }, icon = { Icon(Icons.Rounded.SettingsApplications, null) }, onClick = { scope .launch { onDismissRequest() } .invokeOnCompletion { onNavigateToRoute(Route.GENERAL_DOWNLOAD_PREFERENCES) } }, selected = currentRoute == Route.GENERAL_DOWNLOAD_PREFERENCES, ) NavigationDrawerItem( label = { Text(stringResource(R.string.download_directory)) }, icon = { Icon(Icons.Rounded.Folder, null) }, onClick = { scope .launch { onDismissRequest() } .invokeOnCompletion { onNavigateToRoute(Route.DOWNLOAD_DIRECTORY) } }, selected = currentRoute == Route.DOWNLOAD_DIRECTORY, ) NavigationDrawerItem( label = { Text(stringResource(R.string.cookies)) }, icon = { Icon(Icons.Rounded.Cookie, null) }, onClick = { scope .launch { onDismissRequest() } .invokeOnCompletion { onNavigateToRoute(Route.COOKIE_PROFILE) } }, selected = currentRoute == Route.COOKIE_PROFILE, ) NavigationDrawerItem( label = { Text(stringResource(R.string.trouble_shooting)) }, icon = { Icon(Icons.Rounded.BugReport, null) }, onClick = { scope .launch { onDismissRequest() } .invokeOnCompletion { onNavigateToRoute(Route.TROUBLESHOOTING) } }, selected = currentRoute == Route.TROUBLESHOOTING, ) NavigationDrawerItem( label = { Text(stringResource(R.string.about)) }, icon = { Icon(Icons.Rounded.Info, null) }, onClick = { scope .launch { onDismissRequest() } .invokeOnCompletion { onNavigateToRoute(Route.ABOUT) } }, selected = currentRoute == Route.ABOUT, ) } } Spacer(Modifier.weight(1f)) footer?.invoke() } } @Composable fun NavigationRailItemVariant( modifier: Modifier = Modifier, icon: @Composable (() -> Unit), selected: Boolean, onClick: () -> Unit, ) { Box( modifier = modifier .size(56.dp) .clip(MaterialTheme.shapes.large) .background( if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent ) .selectable(selected = selected, onClick = onClick), contentAlignment = Alignment.Center, ) { CompositionLocalProvider( LocalContentColor provides if (selected) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant ) { icon() } } } @Composable fun NavigationRailContent( modifier: Modifier = Modifier, currentTopDestination: String? = null, onNavigateToRoute: (String) -> Unit, ) { Column( modifier = modifier.selectableGroup(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp), ) { val scope = rememberCoroutineScope() NavigationRailItemVariant( icon = { Icon( if (currentTopDestination == Route.HOME) Icons.Filled.Download else Icons.Outlined.Download, stringResource(R.string.download_queue), ) }, modifier = Modifier, selected = currentTopDestination == Route.HOME, onClick = { onNavigateToRoute(Route.HOME) }, ) NavigationRailItemVariant( icon = { Icon( if (currentTopDestination == Route.DOWNLOADS) Icons.Filled.Subscriptions else Icons.Outlined.Subscriptions, stringResource(R.string.downloads_history), ) }, modifier = Modifier, selected = currentTopDestination == Route.DOWNLOADS, onClick = { onNavigateToRoute(Route.DOWNLOADS) }, ) NavigationRailItemVariant( icon = { Icon( if (currentTopDestination == Route.TASK_LIST) Icons.Filled.Terminal else Icons.Outlined.Terminal, stringResource(R.string.custom_command), ) }, modifier = Modifier, selected = currentTopDestination == Route.TASK_LIST, onClick = { onNavigateToRoute(Route.TASK_LIST) }, ) NavigationRailItemVariant( icon = { Icon( if (currentTopDestination == Route.SETTINGS_PAGE) Icons.Filled.Settings else Icons.Outlined.Settings, stringResource(R.string.settings), ) }, modifier = Modifier, selected = currentTopDestination == Route.SETTINGS_PAGE, onClick = { onNavigateToRoute(Route.SETTINGS_PAGE) }, ) } } @Preview(device = "spec:width=673dp,height=841dp") @Preview(device = "spec:width=1280dp,height=800dp,dpi=240") @Composable private fun ExpandedPreview() { val widthDp = LocalConfiguration.current.screenWidthDp var currentRoute = remember { mutableStateOf(Route.HOME) } CompositionLocalProvider( LocalWindowWidthState provides if (widthDp > 480) WindowWidthSizeClass.Expanded else if (widthDp > 360) WindowWidthSizeClass.Medium else WindowWidthSizeClass.Compact ) { Row { val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) NavigationDrawer( currentRoute = currentRoute.value, currentTopDestination = currentRoute.value, drawerState = drawerState, onNavigateToRoute = { currentRoute.value = it }, onDismissRequest = {}, ) { DownloadPageImplV2(taskDownloadStateMap = remember { mutableStateMapOf() }) { _, _ -> } } } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/UpdateDialog.kt ================================================ package com.junkfood.seal.ui.page import android.os.Build import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.Column import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.NewReleases import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.junkfood.seal.R import com.junkfood.seal.util.ToastUtil import com.junkfood.seal.util.UpdateUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @Composable fun UpdateDialog(onDismissRequest: () -> Unit, release: UpdateUtil.Release) { var currentDownloadStatus by remember { mutableStateOf(UpdateUtil.DownloadStatus.NotYet as UpdateUtil.DownloadStatus) } val context = LocalContext.current val scope = rememberCoroutineScope() UpdateDialogImpl( onDismissRequest = onDismissRequest, title = release.name.toString(), onConfirmUpdate = { scope.launch(Dispatchers.IO) { runCatching { UpdateUtil.downloadApk(release = release).collect { downloadStatus -> currentDownloadStatus = downloadStatus if (downloadStatus is UpdateUtil.DownloadStatus.Finished) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { UpdateUtil.installLatestApk() } } } } .onFailure { it.printStackTrace() currentDownloadStatus = UpdateUtil.DownloadStatus.NotYet ToastUtil.makeToastSuspend(context.getString(R.string.app_update_failed)) return@launch } } }, releaseNote = release.body.toString(), downloadStatus = currentDownloadStatus, ) } @Composable fun UpdateDialogImpl( onDismissRequest: () -> Unit, title: String, onConfirmUpdate: () -> Unit, releaseNote: String, downloadStatus: UpdateUtil.DownloadStatus, ) { AlertDialog( onDismissRequest = {}, title = { Text(title) }, icon = { Icon(Icons.Outlined.NewReleases, null) }, confirmButton = { Button( onClick = { if (downloadStatus !is UpdateUtil.DownloadStatus.Progress) onConfirmUpdate() } ) { Text( when (downloadStatus) { is UpdateUtil.DownloadStatus.Progress -> "${downloadStatus.percent} %" else -> stringResource(R.string.update) }, modifier = Modifier.animateContentSize(), ) } }, dismissButton = { OutlinedButton(onClick = onDismissRequest) { Text(text = stringResource(id = R.string.dismiss)) } }, text = { Column(Modifier.verticalScroll(rememberScrollState())) { Text(releaseNote) } }, ) } @Preview @Composable private fun Preview() { var b by remember { mutableStateOf(false) } val flow: MutableStateFlow = remember { MutableStateFlow(UpdateUtil.DownloadStatus.NotYet) } LaunchedEffect(b) { if (b) { repeat(100) { i -> flow.update { UpdateUtil.DownloadStatus.Progress(percent = i) } delay(50) } } else { flow.update { UpdateUtil.DownloadStatus.NotYet } } } val status by flow.collectAsStateWithLifecycle() UpdateDialogImpl( onDismissRequest = { b = false }, title = "v1.12.0", onConfirmUpdate = { b = true }, releaseNote = "ReleaseNoteHTML", downloadStatus = status, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/WelcomeDialog.kt ================================================ package com.junkfood.seal.ui.page import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ContentPaste import androidx.compose.material.icons.outlined.Downloading import androidx.compose.material.icons.outlined.FileDownload import androidx.compose.material.icons.outlined.SettingsSuggest import androidx.compose.material.icons.outlined.Subscriptions import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.junkfood.seal.R import com.junkfood.seal.ui.component.CheckBoxItem import com.junkfood.seal.util.PreferenceUtil import com.junkfood.seal.util.PreferenceUtil.getInt import com.junkfood.seal.util.WELCOME_DIALOG @Composable fun WelcomeDialog(onClick: () -> Unit) { var showWelcomeDialog by rememberSaveable { mutableIntStateOf(WELCOME_DIALOG.getInt()) } var disableDialog by remember { mutableStateOf(false) } val onDismissRequest = { PreferenceUtil.encodeInt(WELCOME_DIALOG, if (disableDialog) 0 else showWelcomeDialog + 1) showWelcomeDialog = 0 } if (showWelcomeDialog > 0) AlertDialog( onDismissRequest = onDismissRequest, dismissButton = { TextButton(onClick = onDismissRequest) { Text(stringResource(R.string.close)) } }, confirmButton = { TextButton( onClick = { onClick() onDismissRequest() } ) { Text(stringResource(R.string.open_settings)) } }, title = { Text(stringResource(R.string.user_guide)) }, text = { Column(modifier = Modifier.verticalScroll(rememberScrollState())) { IconDescription( icon = Icons.Outlined.ContentPaste, description = stringResource(R.string.paste_desc), ) IconDescription( icon = Icons.Outlined.FileDownload, description = stringResource(R.string.download_desc), ) IconDescription( icon = Icons.Outlined.Subscriptions, description = stringResource(R.string.download_history_desc), ) IconDescription( icon = Icons.Outlined.Downloading, description = stringResource(R.string.battery_settings_desc), ) IconDescription( icon = Icons.Outlined.SettingsSuggest, description = stringResource(R.string.check_download_settings_desc), ) if ((showWelcomeDialog > 1)) CheckBoxItem( text = stringResource(id = R.string.close_never_show_again), checked = disableDialog, onValueChange = { disableDialog = !disableDialog }, ) } }, ) } @Composable fun IconDescription(modifier: Modifier = Modifier, icon: ImageVector, description: String) { Row( modifier = modifier.padding(top = 12.dp, bottom = 9.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon(modifier = Modifier.size(24.dp), imageVector = icon, contentDescription = null) Text(modifier = Modifier.padding(start = 12.dp), text = description) } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/YtdlpUpdater.kt ================================================ package com.junkfood.seal.ui.page import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.junkfood.seal.Downloader import com.junkfood.seal.util.PreferenceUtil import com.junkfood.seal.util.PreferenceUtil.getBoolean import com.junkfood.seal.util.PreferenceUtil.getLong import com.junkfood.seal.util.PreferenceUtil.getString import com.junkfood.seal.util.UpdateUtil import com.junkfood.seal.util.YT_DLP_AUTO_UPDATE import com.junkfood.seal.util.YT_DLP_UPDATE_INTERVAL import com.junkfood.seal.util.YT_DLP_UPDATE_TIME import com.junkfood.seal.util.YT_DLP_VERSION import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @Composable fun YtdlpUpdater() { val downloaderState by Downloader.downloaderState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { if (downloaderState !is Downloader.State.Idle) return@LaunchedEffect if (!YT_DLP_AUTO_UPDATE.getBoolean() && YT_DLP_VERSION.getString().isNotEmpty()) return@LaunchedEffect if (!PreferenceUtil.isNetworkAvailableForDownload()) { return@LaunchedEffect } val lastUpdateTime = YT_DLP_UPDATE_TIME.getLong() val currentTime = System.currentTimeMillis() if (currentTime < lastUpdateTime + YT_DLP_UPDATE_INTERVAL.getLong()) { return@LaunchedEffect } runCatching { Downloader.updateState(state = Downloader.State.Updating) withContext(Dispatchers.IO) { UpdateUtil.updateYtDlp() } } .onFailure { it.printStackTrace() } Downloader.updateState(state = Downloader.State.Idle) } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/command/TaskListPage.kt ================================================ package com.junkfood.seal.ui.page.command import androidx.activity.compose.BackHandler import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.selectable import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Cancel import androidx.compose.material.icons.outlined.Code import androidx.compose.material.icons.outlined.DownloadDone import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.NewLabel import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.junkfood.seal.Downloader import com.junkfood.seal.R import com.junkfood.seal.database.objects.CommandTemplate import com.junkfood.seal.ui.common.HapticFeedback.slightHapticFeedback import com.junkfood.seal.ui.common.intState import com.junkfood.seal.ui.component.BackButton import com.junkfood.seal.ui.component.ClearButton import com.junkfood.seal.ui.component.CustomCommandTaskItem import com.junkfood.seal.ui.component.DismissButton import com.junkfood.seal.ui.component.FilledButtonWithIcon import com.junkfood.seal.ui.component.OutlinedButtonChip import com.junkfood.seal.ui.component.OutlinedButtonWithIcon import com.junkfood.seal.ui.component.PasteFromClipBoardButton import com.junkfood.seal.ui.component.SealDialog import com.junkfood.seal.ui.component.SealModalBottomSheetM2 import com.junkfood.seal.ui.component.TaskStatus import com.junkfood.seal.ui.page.settings.command.CommandTemplateDialog import com.junkfood.seal.util.PreferenceUtil import com.junkfood.seal.util.PreferenceUtil.updateInt import com.junkfood.seal.util.TEMPLATE_ID import com.junkfood.seal.util.matchUrlFromString import kotlinx.coroutines.delay import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun TaskListPage(onNavigateBack: () -> Unit, onNavigateToDetail: (Int) -> Unit) { val scope = rememberCoroutineScope() val view = LocalView.current val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() var showBottomSheet by remember { mutableStateOf(false) } val sheetState = androidx.compose.material.rememberModalBottomSheetState( skipHalfExpanded = true, initialValue = ModalBottomSheetValue.Hidden, ) Scaffold( modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { TopAppBar( title = { Text( text = stringResource(R.string.running_tasks), style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp), ) }, navigationIcon = { BackButton { onNavigateBack() } }, actions = {}, scrollBehavior = scrollBehavior, ) }, floatingActionButton = { FloatingActionButton( onClick = { scope.launch { showBottomSheet = true delay(50) sheetState.show() } }, modifier = Modifier.padding(vertical = 18.dp, horizontal = 6.dp), ) { Icon(Icons.Outlined.Add, stringResource(id = R.string.new_task)) } }, ) { paddings -> val clipboardManager = LocalClipboardManager.current LazyColumn( modifier = Modifier.padding(paddings), contentPadding = PaddingValues(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { items( Downloader.mutableTaskList.values.toList().sortedBy { it.state.toStatus() }, key = { it.toKey() }, ) { it.run { CustomCommandTaskItem( status = state.toStatus(), progress = if (state is Downloader.CustomCommandTask.State.Running) state.progress / 100f else 0f, progressText = currentLine, url = url, templateName = template.name, onCancel = { onCancel() }, onCopyError = { onCopyError(clipboardManager) }, onRestart = { onRestart() }, onCopyLog = { onCopyLog(clipboardManager) }, onShowLog = { onNavigateToDetail(hashCode()) }, modifier = Modifier.animateItem(), ) } } } } val onDismissRequest: () -> Unit = { scope.launch { sheetState.hide() }.invokeOnCompletion { showBottomSheet = false } } BackHandler(showBottomSheet) { onDismissRequest() } if (showBottomSheet) SealModalBottomSheetM2( sheetState = sheetState, sheetContent = { val clipboardManager = LocalClipboardManager.current var showTemplateSelectionDialog by remember { mutableStateOf(false) } var showTemplateCreatorDialog by remember { mutableStateOf(false) } var showTemplateEditorDialog by remember { mutableStateOf(false) } val template by remember( showTemplateCreatorDialog, showTemplateSelectionDialog, showTemplateEditorDialog, ) { mutableStateOf(PreferenceUtil.getTemplate()) } var url by remember { mutableStateOf("") } LaunchedEffect(sheetState.targetValue) { if (sheetState.targetValue == ModalBottomSheetValue.Expanded) url = matchUrlFromString(clipboardManager.getText()?.text.toString(), true) } Column(Modifier.fillMaxWidth()) { TaskCreatorDialogContent( url = url, onValueChange = { url = it }, template = template, onTemplateSelectionClicked = { showTemplateSelectionDialog = true }, onNewTemplateClicked = { showTemplateCreatorDialog = true }, onEditClicked = { showTemplateEditorDialog = true }, ) LazyRow( modifier = Modifier.fillMaxWidth().padding(top = 24.dp), horizontalArrangement = Arrangement.End, ) { item { OutlinedButtonWithIcon( modifier = Modifier.padding(horizontal = 12.dp), onClick = onDismissRequest, icon = Icons.Outlined.Cancel, text = stringResource(R.string.cancel), ) } item { FilledButtonWithIcon( onClick = { view.slightHapticFeedback() Downloader.executeCommandWithUrl(url) onDismissRequest() }, icon = Icons.Outlined.DownloadDone, text = stringResource(R.string.start), ) } } } if (showTemplateSelectionDialog) { TemplatePickerDialog() { showTemplateSelectionDialog = false } } if (showTemplateCreatorDialog) { CommandTemplateDialog( onDismissRequest = { showTemplateCreatorDialog = false }, confirmationCallback = { scope.launch { TEMPLATE_ID.updateInt(it) } }, ) } if (showTemplateEditorDialog) { CommandTemplateDialog( commandTemplate = template, onDismissRequest = { showTemplateEditorDialog = false }, ) } }, ) } private fun Downloader.CustomCommandTask.State.toStatus(): TaskStatus = when (this) { Downloader.CustomCommandTask.State.Canceled -> TaskStatus.CANCELED Downloader.CustomCommandTask.State.Completed -> TaskStatus.FINISHED is Downloader.CustomCommandTask.State.Error -> TaskStatus.ERROR is Downloader.CustomCommandTask.State.Running -> TaskStatus.RUNNING } @Composable fun ColumnScope.TaskCreatorDialogContent( url: String, onValueChange: (String) -> Unit = {}, template: CommandTemplate, onTemplateSelectionClicked: () -> Unit = {}, onNewTemplateClicked: () -> Unit = {}, onEditClicked: () -> Unit = {}, ) { Icon( modifier = Modifier.align(Alignment.CenterHorizontally), imageVector = Icons.Outlined.Add, contentDescription = null, ) Text( text = stringResource(id = R.string.new_task), style = MaterialTheme.typography.headlineSmall, modifier = Modifier.align(Alignment.CenterHorizontally).padding(vertical = 16.dp), maxLines = 2, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, ) Text( text = stringResource(R.string.custom_command_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, modifier = Modifier.align(Alignment.CenterHorizontally).padding(bottom = 24.dp), ) OutlinedTextField( value = url, onValueChange = onValueChange, label = { Text(text = stringResource(id = R.string.video_url)) }, modifier = Modifier.fillMaxWidth(), minLines = 3, maxLines = 3, trailingIcon = { if (url.isNotEmpty()) { ClearButton { onValueChange("") } } else { PasteFromClipBoardButton(onPaste = onValueChange) } }, textStyle = LocalTextStyle.current.merge(fontFamily = FontFamily.Monospace), ) LazyRow( modifier = Modifier.padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { item { OutlinedButtonChip( icon = Icons.Outlined.Code, label = template.name, onClick = onTemplateSelectionClicked, ) } item { OutlinedButtonChip( icon = Icons.Outlined.Edit, label = stringResource(id = R.string.edit_template, template.name), onClick = onEditClicked, ) } item { OutlinedButtonChip( icon = Icons.Outlined.NewLabel, label = stringResource(id = R.string.new_template), onClick = onNewTemplateClicked, ) } } } @Composable fun TemplatePickerDialog(onDismissRequest: () -> Unit = {}) { val templateList by PreferenceUtil.templateListStateFlow.collectAsStateWithLifecycle() var selectedId by TEMPLATE_ID.intState val scrollState = rememberLazyListState( initialFirstVisibleItemIndex = templateList .indexOfFirst { it.id == selectedId } .run { if (this == -1) 0 else this } ) SealDialog( onDismissRequest = onDismissRequest, confirmButton = { DismissButton(onClick = onDismissRequest) }, title = { Text(text = stringResource(id = R.string.template_selection)) }, icon = { Icon(imageVector = Icons.Outlined.Code, contentDescription = null) }, text = { Box(modifier = Modifier.heightIn(max = 450.dp)) { androidx.compose.material3.HorizontalDivider( modifier = Modifier.align(Alignment.TopCenter) ) LazyColumn(state = scrollState) { item { Spacer(modifier = Modifier.height(4.dp)) } items(templateList) { TemplateSingleChoiceItem( text = it.name, supportingText = it.template, selected = it.id == selectedId, ) { selectedId = it.id TEMPLATE_ID.updateInt(it.id) onDismissRequest() } } item { Spacer(modifier = Modifier.height(4.dp)) } } androidx.compose.material3.HorizontalDivider( modifier = Modifier.align(Alignment.BottomCenter) ) } }, ) } @Composable fun TemplateSingleChoiceItem( modifier: Modifier = Modifier, text: String, supportingText: String, selected: Boolean, onClick: () -> Unit, ) { Row( modifier = modifier .selectable(selected = selected, enabled = true, onClick = onClick) .fillMaxWidth() .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start, ) { RadioButton( modifier = Modifier.padding(end = 8.dp).clearAndSetSemantics {}, selected = selected, onClick = onClick, ) Column { Text( text = text, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( text = supportingText.replace("\n", " "), style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/command/TaskLogPage.kt ================================================ package com.junkfood.seal.ui.page.command import android.util.Log import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Cancel import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material.icons.outlined.RestartAlt import androidx.compose.material.icons.outlined.UnfoldMore import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Divider import androidx.compose.material3.ElevatedAssistChip import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.junkfood.seal.Downloader import com.junkfood.seal.R import com.junkfood.seal.ui.component.ButtonChip private const val TAG = "TaskLogPage" @OptIn(ExperimentalMaterial3Api::class) @Composable fun TaskLogPage(onNavigateBack: () -> Unit, taskHashCode: Int) { Log.d(TAG, "TaskLogPage: $taskHashCode") val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val task = Downloader.mutableTaskList.values.find { it.hashCode() == taskHashCode } ?: return val clipboardManager = LocalClipboardManager.current var expandLog by remember { mutableStateOf(false) } Scaffold( modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { TopAppBar( title = { Text( text = stringResource(R.string.logs), style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp), ) }, navigationIcon = { IconButton(onClick = { onNavigateBack() }) { Icon(Icons.Outlined.Close, stringResource(R.string.close)) } }, actions = {}, scrollBehavior = scrollBehavior, ) }, bottomBar = { Column( modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp).navigationBarsPadding(), verticalArrangement = Arrangement.Center, ) { Divider(modifier = Modifier.fillMaxWidth()) Row( Modifier.fillMaxWidth() .horizontalScroll(rememberScrollState()) .padding(horizontal = 16.dp) ) { task.run { ButtonChip( icon = Icons.Outlined.ContentCopy, label = stringResource(id = R.string.copy_log), ) { onCopyLog(clipboardManager) } if (state is Downloader.CustomCommandTask.State.Error) ButtonChip( icon = Icons.Outlined.ErrorOutline, label = stringResource(id = R.string.copy_error_report), iconColor = MaterialTheme.colorScheme.error, ) { onCopyError(clipboardManager) } if (state is Downloader.CustomCommandTask.State.Running) ButtonChip( icon = Icons.Outlined.Cancel, label = stringResource(id = R.string.cancel), iconColor = MaterialTheme.colorScheme.onSurfaceVariant, ) { onCancel() } if ( state is Downloader.CustomCommandTask.State.Canceled || state is Downloader.CustomCommandTask.State.Error ) ButtonChip( icon = Icons.Outlined.RestartAlt, label = stringResource(id = R.string.restart), ) { onRestart() } if (!expandLog) ElevatedAssistChip( modifier = Modifier.padding(horizontal = 4.dp), onClick = { expandLog = true }, label = { Text(stringResource(id = R.string.expand)) }, leadingIcon = { Icon( imageVector = Icons.Outlined.UnfoldMore, null, modifier = Modifier.size(AssistChipDefaults.IconSize).rotate(90f), ) }, ) } } } }, ) { paddings -> val scrollState = rememberScrollState() LaunchedEffect(key1 = scrollState.maxValue) { scrollState.animateScrollTo(scrollState.maxValue) } Column(modifier = Modifier.padding(paddings).fillMaxSize().verticalScroll(scrollState)) { SelectionContainer() { Text( modifier = Modifier.run { if (expandLog) horizontalScroll(rememberScrollState()) else this } .padding(top = 12.dp) .padding(horizontal = 20.dp), text = task.output, style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), ) } } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/download/DownloadPage.kt ================================================ package com.junkfood.seal.ui.page.download import android.Manifest import android.os.Build import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Cancel import androidx.compose.material.icons.outlined.ContentPaste import androidx.compose.material.icons.outlined.Error import androidx.compose.material.icons.outlined.FileDownload import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Subscriptions import androidx.compose.material.icons.outlined.Terminal import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.TopAppBar import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberTooltipState import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import com.junkfood.seal.App import com.junkfood.seal.Downloader import com.junkfood.seal.R import com.junkfood.seal.download.DownloaderV2 import com.junkfood.seal.ui.common.HapticFeedback.longPressHapticFeedback import com.junkfood.seal.ui.common.HapticFeedback.slightHapticFeedback import com.junkfood.seal.ui.common.LocalWindowWidthState import com.junkfood.seal.ui.component.ClearButton import com.junkfood.seal.ui.component.NavigationBarSpacer import com.junkfood.seal.ui.component.OutlinedButtonWithIcon import com.junkfood.seal.ui.component.VideoCard import com.junkfood.seal.ui.page.downloadv2.configure.Config import com.junkfood.seal.ui.page.downloadv2.configure.DownloadDialog import com.junkfood.seal.ui.page.downloadv2.configure.DownloadDialogViewModel import com.junkfood.seal.ui.page.downloadv2.configure.DownloadDialogViewModel.Action import com.junkfood.seal.ui.page.downloadv2.configure.FormatPage import com.junkfood.seal.ui.theme.PreviewThemeLight import com.junkfood.seal.ui.theme.SealTheme import com.junkfood.seal.util.CELLULAR_DOWNLOAD import com.junkfood.seal.util.CONFIGURE import com.junkfood.seal.util.CUSTOM_COMMAND import com.junkfood.seal.util.DEBUG import com.junkfood.seal.util.DISABLE_PREVIEW import com.junkfood.seal.util.DownloadUtil import com.junkfood.seal.util.NOTIFICATION import com.junkfood.seal.util.PreferenceUtil import com.junkfood.seal.util.PreferenceUtil.getBoolean import com.junkfood.seal.util.PreferenceUtil.updateBoolean import com.junkfood.seal.util.ToastUtil import com.junkfood.seal.util.matchUrlFromClipboard import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject @OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) @Composable fun DownloadPage( navigateToSettings: () -> Unit = {}, navigateToDownloads: () -> Unit = {}, navigateToPlaylistPage: () -> Unit = {}, navigateToFormatPage: () -> Unit = {}, onNavigateToTaskList: () -> Unit = {}, onNavigateToCookieGeneratorPage: (String) -> Unit = {}, downloader: DownloaderV2 = koinInject(), homePageViewModel: HomePageViewModel = koinViewModel(), dialogViewModel: DownloadDialogViewModel = koinViewModel(), ) { val scope = rememberCoroutineScope() val downloaderState by Downloader.downloaderState.collectAsStateWithLifecycle() val taskState by Downloader.taskState.collectAsStateWithLifecycle() val viewState by homePageViewModel.viewStateFlow.collectAsStateWithLifecycle() val playlistInfo by Downloader.playlistResult.collectAsStateWithLifecycle() val videoInfo by homePageViewModel.videoInfoFlow.collectAsStateWithLifecycle() val errorState by Downloader.errorState.collectAsStateWithLifecycle() val processCount by Downloader.processCount.collectAsStateWithLifecycle() var showNotificationDialog by remember { mutableStateOf(false) } val notificationPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS) { isGranted: Boolean -> showNotificationDialog = false if (!isGranted) { ToastUtil.makeToast(R.string.permission_denied) } } } else null val clipboardManager = LocalClipboardManager.current val keyboardController = LocalSoftwareKeyboardController.current val useDialog = LocalWindowWidthState.current != WindowWidthSizeClass.Compact val view = LocalView.current var showDownloadDialog by rememberSaveable { mutableStateOf(false) } var showMeteredNetworkDialog by remember { mutableStateOf(false) } val checkNetworkOrDownload = { if (!PreferenceUtil.isNetworkAvailableForDownload()) { showMeteredNetworkDialog = true } else { dialogViewModel.postAction(Action.ShowSheet(listOf(viewState.url))) // downloadViewModel.startDownloadVideo() } } val storagePermission = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE) { b: Boolean -> if (b) { checkNetworkOrDownload() } else { ToastUtil.makeToast(R.string.permission_denied) } } val checkPermissionOrDownload = { if (Build.VERSION.SDK_INT > 29 || storagePermission.status == PermissionStatus.Granted) { checkNetworkOrDownload() } else { storagePermission.launchPermissionRequest() } } val downloadCallback: () -> Unit = { view.slightHapticFeedback() keyboardController?.hide() if (NOTIFICATION.getBoolean() && notificationPermission?.status?.isGranted == false) { showNotificationDialog = true } if (CONFIGURE.getBoolean()) { showDownloadDialog = true } else { checkPermissionOrDownload() } } if (showNotificationDialog) { NotificationPermissionDialog( onDismissRequest = { showNotificationDialog = false NOTIFICATION.updateBoolean(false) }, onPermissionGranted = { notificationPermission?.launchPermissionRequest() }, ) } if (showMeteredNetworkDialog) { MeteredNetworkDialog( onDismissRequest = { showMeteredNetworkDialog = false }, onAllowOnceConfirm = { homePageViewModel.startDownloadVideo() showMeteredNetworkDialog = false }, onAllowAlwaysConfirm = { homePageViewModel.startDownloadVideo() CELLULAR_DOWNLOAD.updateBoolean(true) showMeteredNetworkDialog = false }, ) } DisposableEffect(viewState.showPlaylistSelectionDialog) { if (!playlistInfo.entries.isNullOrEmpty() && viewState.showPlaylistSelectionDialog) navigateToPlaylistPage() onDispose { homePageViewModel.hidePlaylistDialog() } } DisposableEffect(viewState.showFormatSelectionPage) { if (viewState.showFormatSelectionPage) { if (!videoInfo.formats.isNullOrEmpty()) navigateToFormatPage() } onDispose { homePageViewModel.hideFormatPage() } } var showOutput by remember { mutableStateOf(DEBUG.getBoolean()) } LaunchedEffect(downloaderState) { showOutput = DEBUG.getBoolean() && downloaderState !is Downloader.State.Idle } if (viewState.isUrlSharingTriggered) { homePageViewModel.onShareIntentConsumed() downloadCallback() } val showVideoCard by remember(downloaderState) { mutableStateOf(!DISABLE_PREVIEW.getBoolean()) } Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { DownloadPageImpl( downloaderState = downloaderState, taskState = taskState, viewState = viewState, errorState = errorState, downloadCallback = { dialogViewModel.postAction(Action.ShowSheet()) }, navigateToSettings = navigateToSettings, navigateToDownloads = navigateToDownloads, onNavigateToTaskList = onNavigateToTaskList, processCount = processCount, showVideoCard = showVideoCard, showOutput = showOutput, showDownloadProgress = taskState.taskId.isNotEmpty(), pasteCallback = { matchUrlFromClipboard( string = clipboardManager.getText().toString(), isMatchingMultiLink = CUSTOM_COMMAND.getBoolean(), ) .let { homePageViewModel.updateUrl(it) } }, cancelCallback = { Downloader.cancelDownload() }, onVideoCardClicked = { Downloader.openDownloadResult() }, onUrlChanged = { url -> homePageViewModel.updateUrl(url) }, ) { Column { downloader.getTaskStateMap().forEach { (task, state) -> Text(state.viewState.toString(), maxLines = 2) Text(state.toString()) Spacer(Modifier.height(12.dp)) } } } var preferences by remember { mutableStateOf(DownloadUtil.DownloadPreferences.createFromPreferences()) } val sheetValue = dialogViewModel.sheetValueFlow.collectAsStateWithLifecycle().value val state = dialogViewModel.sheetStateFlow.collectAsStateWithLifecycle().value val selectionState = dialogViewModel.selectionStateFlow.collectAsStateWithLifecycle().value var showDialog by remember { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) LaunchedEffect(sheetValue) { if (sheetValue == DownloadDialogViewModel.SheetValue.Expanded) { showDialog = true } else { launch { sheetState.hide() }.invokeOnCompletion { showDialog = false } } } if (showDialog) { DownloadDialog( state = state, sheetState = sheetState, config = Config(), preferences = preferences, onPreferencesUpdate = { preferences = it }, onActionPost = { dialogViewModel.postAction(it) }, ) } when (selectionState) { is DownloadDialogViewModel.SelectionState.FormatSelection -> FormatPage( state = selectionState, onDismissRequest = { dialogViewModel.postAction(Action.Reset) }, ) else -> {} } DownloadSettingDialog( useDialog = useDialog, showDialog = showDownloadDialog, onNavigateToCookieGeneratorPage = onNavigateToCookieGeneratorPage, onDownloadConfirm = { checkPermissionOrDownload() }, onDismissRequest = { showDownloadDialog = false }, ) } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun DownloadPageImpl( downloaderState: Downloader.State, taskState: Downloader.DownloadTaskItem, viewState: HomePageViewModel.ViewState, errorState: Downloader.ErrorState, showVideoCard: Boolean = false, showOutput: Boolean = false, showDownloadProgress: Boolean = false, processCount: Int = 0, downloadCallback: () -> Unit = {}, navigateToSettings: () -> Unit = {}, navigateToDownloads: () -> Unit = {}, onNavigateToTaskList: () -> Unit = {}, pasteCallback: () -> Unit = {}, cancelCallback: () -> Unit = {}, onVideoCardClicked: () -> Unit = {}, onUrlChanged: (String) -> Unit = {}, isPreview: Boolean = false, content: @Composable () -> Unit, ) { val view = LocalView.current val clipboardManager = LocalClipboardManager.current val showCancelButton = downloaderState is Downloader.State.DownloadingPlaylist || downloaderState is Downloader.State.DownloadingVideo Scaffold( modifier = Modifier.fillMaxSize(), topBar = { TopAppBar( title = {}, modifier = Modifier.padding(horizontal = 8.dp), navigationIcon = { TooltipBox( state = rememberTooltipState(), positionProvider = TooltipDefaults.rememberTooltipPositionProvider(), tooltip = { PlainTooltip { Text(text = stringResource(id = R.string.settings)) } }, ) { IconButton( onClick = { view.slightHapticFeedback() navigateToSettings() }, modifier = Modifier, ) { Icon( imageVector = Icons.Outlined.Settings, contentDescription = stringResource(id = R.string.settings), ) } } }, actions = { BadgedBox( badge = { if (processCount > 0) Badge(modifier = Modifier.offset(x = (-16).dp, y = (8).dp)) { Text("$processCount") } } ) { TooltipBox( state = rememberTooltipState(), positionProvider = TooltipDefaults.rememberTooltipPositionProvider(), tooltip = { PlainTooltip { Text(text = stringResource(id = R.string.running_tasks)) } }, ) { IconButton( onClick = { view.slightHapticFeedback() onNavigateToTaskList() }, modifier = Modifier, ) { Icon( imageVector = Icons.Outlined.Terminal, contentDescription = stringResource(id = R.string.running_tasks), ) } } } TooltipBox( state = rememberTooltipState(), positionProvider = TooltipDefaults.rememberTooltipPositionProvider(), tooltip = { PlainTooltip { Text(text = stringResource(id = R.string.downloads_history)) } }, ) { IconButton( onClick = { view.slightHapticFeedback() navigateToDownloads() }, modifier = Modifier, ) { Icon( imageVector = Icons.Outlined.Subscriptions, contentDescription = stringResource(id = R.string.downloads_history), ) } } }, ) }, floatingActionButton = { FABs( modifier = with(receiver = Modifier) { if (showDownloadProgress) this else this.imePadding() }, downloadCallback = downloadCallback, pasteCallback = pasteCallback, ) }, ) { Column( modifier = Modifier.padding(it).fillMaxSize().verticalScroll(rememberScrollState()) ) { TitleWithProgressIndicator( showProgressIndicator = downloaderState is Downloader.State.FetchingInfo, isDownloadingPlaylist = downloaderState is Downloader.State.DownloadingPlaylist, showDownloadText = showCancelButton, currentIndex = downloaderState.run { if (this is Downloader.State.DownloadingPlaylist) currentItem else 0 }, downloadItemCount = downloaderState.run { if (this is Downloader.State.DownloadingPlaylist) itemCount else 0 }, ) Column(Modifier.padding(horizontal = 24.dp).padding(top = 24.dp)) { with(taskState) { AnimatedVisibility(visible = showDownloadProgress && showVideoCard) { Box() { VideoCard( modifier = Modifier, title = title, author = uploader, thumbnailUrl = thumbnailUrl, progress = progress, showCancelButton = downloaderState is Downloader.State.DownloadingPlaylist || downloaderState is Downloader.State.DownloadingVideo, onCancel = cancelCallback, fileSizeApprox = fileSizeApprox, duration = duration, onClick = onVideoCardClicked, isPreview = isPreview, ) } } InputUrl( url = viewState.url, progress = progress, showDownloadProgress = showDownloadProgress && !showVideoCard, error = errorState != Downloader.ErrorState.None, showCancelButton = showCancelButton && !showVideoCard, onCancel = cancelCallback, onDone = downloadCallback, ) { url -> onUrlChanged(url) } AnimatedVisibility( modifier = Modifier.fillMaxWidth(), enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut(), visible = progressText.isNotEmpty() && showOutput, ) { Text( modifier = Modifier.padding(bottom = 12.dp), text = progressText, maxLines = 3, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium, ) } } AnimatedVisibility(visible = errorState != Downloader.ErrorState.None) { ErrorMessage(title = errorState.title, errorReport = errorState.report) { view.longPressHapticFeedback() clipboardManager.setText( AnnotatedString( App.getVersionReport() + "\nURL: ${errorState.url}\n${errorState.report}" ) ) ToastUtil.makeToast(R.string.error_copied) } } content() NavigationBarSpacer() Spacer(modifier = Modifier.height(160.dp)) } } } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable fun InputUrl( url: String, error: Boolean, showDownloadProgress: Boolean = false, progress: Float, onDone: () -> Unit, showCancelButton: Boolean, onCancel: () -> Unit, onValueChange: (String) -> Unit, ) { val softwareKeyboardController = LocalSoftwareKeyboardController.current OutlinedTextField( value = url, isError = error, onValueChange = onValueChange, label = { Text(stringResource(R.string.video_url)) }, modifier = Modifier.padding(0f.dp, 16f.dp).fillMaxWidth(), textStyle = MaterialTheme.typography.bodyLarge, maxLines = 3, trailingIcon = { if (url.isNotEmpty()) ClearButton { onValueChange("") } // else PasteUrlButton { onPaste() } }, keyboardActions = KeyboardActions(onDone = { onDone() }), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), ) AnimatedVisibility(visible = showDownloadProgress) { Row(Modifier.padding(0.dp, 12.dp), verticalAlignment = Alignment.CenterVertically) { val progressAnimationValue by animateFloatAsState( targetValue = progress / 100f, animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), ) if (progressAnimationValue < 0) LinearProgressIndicator( modifier = Modifier.weight(0.75f).clip(MaterialTheme.shapes.large) ) else LinearProgressIndicator( progress = { progressAnimationValue }, modifier = Modifier.weight(0.75f).clip(MaterialTheme.shapes.large), ) Text( text = if (progress < 0) "0%" else "$progress%", textAlign = TextAlign.Center, modifier = Modifier.weight(0.25f), ) } } Column(modifier = Modifier.fillMaxWidth()) { AnimatedVisibility(visible = showCancelButton) { OutlinedButtonWithIcon( onClick = onCancel, icon = Icons.Outlined.Cancel, text = stringResource(id = R.string.cancel), contentColor = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } @OptIn(ExperimentalFoundationApi::class) @Composable fun TitleWithProgressIndicator( showProgressIndicator: Boolean = true, showDownloadText: Boolean = true, isDownloadingPlaylist: Boolean = true, currentIndex: Int = 1, downloadItemCount: Int = 4, ) { Column(modifier = Modifier.padding(start = 12.dp, top = 24.dp)) { Row( modifier = Modifier.clip(MaterialTheme.shapes.extraLarge) .padding(horizontal = 12.dp) .padding(top = 12.dp, bottom = 3.dp) ) { Text( modifier = Modifier, text = stringResource(R.string.app_name), style = MaterialTheme.typography.displaySmall, ) AnimatedVisibility(visible = showProgressIndicator) { Column(modifier = Modifier.padding(start = 12.dp)) { CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 3.dp) } } } AnimatedVisibility(visible = showDownloadText) { Text( if (isDownloadingPlaylist) stringResource(R.string.playlist_indicator_text) .format(currentIndex, downloadItemCount) else stringResource(R.string.downloading_indicator_text), modifier = Modifier.padding(start = 12.dp, top = 3.dp), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } @Composable fun ErrorMessage( modifier: Modifier = Modifier, title: String, errorReport: String, onButtonClicked: () -> Unit = {}, ) { val view = LocalView.current Surface( color = MaterialTheme.colorScheme.errorContainer, shape = MaterialTheme.shapes.large, modifier = Modifier.padding(vertical = 16.dp), ) { Column( modifier = Modifier.animateContentSize().padding(horizontal = 12.dp, vertical = 16.dp) ) { Row( modifier = modifier.fillMaxWidth().padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( Icons.Outlined.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error, ) Spacer(modifier = Modifier.width(12.dp)) Column { Text( maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier, text = title, color = MaterialTheme.colorScheme.onErrorContainer, style = MaterialTheme.typography.titleMedium, ) } } Spacer(modifier = Modifier.height(12.dp)) var isExpanded by remember { mutableStateOf(false) } Text( text = errorReport, style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), overflow = TextOverflow.Ellipsis, maxLines = if (isExpanded) Int.MAX_VALUE else 8, modifier = Modifier.clip(MaterialTheme.shapes.small) .clickable( enabled = !isExpanded, onClickLabel = stringResource(id = R.string.expand), onClick = { view.slightHapticFeedback() isExpanded = true }, ) .padding(4.dp), onTextLayout = { isExpanded = !it.hasVisualOverflow }, ) Spacer(modifier = Modifier.height(8.dp)) Row(modifier = Modifier.align(Alignment.End)) { TextButton( onClick = onButtonClicked, colors = ButtonDefaults.textButtonColors( contentColor = MaterialTheme.colorScheme.error ), ) { Text(text = stringResource(id = R.string.copy_error_report)) } } } } } @Preview @Composable private fun ErrorPreview() { SealTheme { Surface { LazyColumn { item { ErrorMessage( title = stringResource(id = R.string.download_error_msg), errorReport = ERROR_REPORT_SAMPLE, ) {} } } } } } @Composable fun FABs( modifier: Modifier = Modifier, downloadCallback: () -> Unit = {}, pasteCallback: () -> Unit = {}, ) { Column(modifier = modifier.padding(6.dp), horizontalAlignment = Alignment.End) { FloatingActionButton( onClick = pasteCallback, content = { Icon( Icons.Outlined.ContentPaste, contentDescription = stringResource(R.string.paste), ) }, modifier = Modifier.padding(vertical = 12.dp), ) FloatingActionButton( onClick = downloadCallback, content = { Icon( Icons.Outlined.FileDownload, contentDescription = stringResource(R.string.download), ) }, modifier = Modifier.padding(vertical = 12.dp), ) } } @Composable @Preview fun DownloadPagePreview() { PreviewThemeLight { Column() { DownloadPageImpl( downloaderState = Downloader.State.DownloadingVideo, taskState = Downloader.DownloadTaskItem(), viewState = HomePageViewModel.ViewState(), errorState = Downloader.ErrorState.DownloadError(url = "", report = ERROR_REPORT_SAMPLE), processCount = 99, isPreview = true, showDownloadProgress = true, showVideoCard = false, ) {} } } } private const val ERROR_REPORT_SAMPLE = """[sample] Extracting URL: https://www.example.com [sample] sample: Downloading webpage [sample] sample: Downloading android player API JSON [info] Available automatic captions for sample: [info] Available automatic captions for sample: [sample] sample: Downloading android player API JSON [info] Available automatic captions for sample: [info] Available automatic captions for sample:""" ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/download/DownloadSettingsDialog.kt ================================================ package com.junkfood.seal.ui.page.download import android.os.Build import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.tween import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AudioFile import androidx.compose.material.icons.outlined.Cancel import androidx.compose.material.icons.outlined.Code import androidx.compose.material.icons.outlined.DoneAll import androidx.compose.material.icons.outlined.DownloadDone import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.HighQuality import androidx.compose.material.icons.outlined.NewLabel import androidx.compose.material.icons.outlined.Sync import androidx.compose.material.icons.outlined.VideoFile import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.junkfood.seal.R import com.junkfood.seal.ui.common.booleanState import com.junkfood.seal.ui.common.intState import com.junkfood.seal.ui.common.motion.materialSharedAxisYIn import com.junkfood.seal.ui.component.ButtonChip import com.junkfood.seal.ui.component.DismissButton import com.junkfood.seal.ui.component.DrawerSheetSubtitle import com.junkfood.seal.ui.component.FilledButtonWithIcon import com.junkfood.seal.ui.component.OutlinedButtonWithIcon import com.junkfood.seal.ui.component.SealModalBottomSheet import com.junkfood.seal.ui.component.SealModalBottomSheetM2 import com.junkfood.seal.ui.component.SingleChoiceChip import com.junkfood.seal.ui.component.VideoFilterChip import com.junkfood.seal.ui.page.command.TemplatePickerDialog import com.junkfood.seal.ui.page.settings.command.CommandTemplateDialog import com.junkfood.seal.ui.page.settings.format.AudioConversionQuickSettingsDialog import com.junkfood.seal.ui.page.settings.format.FormatSortingDialog import com.junkfood.seal.ui.page.settings.format.VideoFormatDialog import com.junkfood.seal.ui.page.settings.format.VideoQualityDialog import com.junkfood.seal.ui.page.settings.network.CookiesQuickSettingsDialog import com.junkfood.seal.util.AUDIO_CONVERSION_FORMAT import com.junkfood.seal.util.AUDIO_CONVERT import com.junkfood.seal.util.CONVERT_M4A import com.junkfood.seal.util.CONVERT_MP3 import com.junkfood.seal.util.COOKIES import com.junkfood.seal.util.CUSTOM_COMMAND import com.junkfood.seal.util.DOWNLOAD_TYPE_INITIALIZATION import com.junkfood.seal.util.DatabaseUtil import com.junkfood.seal.util.DownloadUtil import com.junkfood.seal.util.DownloadUtil.toFormatSorter import com.junkfood.seal.util.EXTRACT_AUDIO import com.junkfood.seal.util.FORMAT_SELECTION import com.junkfood.seal.util.FORMAT_SORTING import com.junkfood.seal.util.FileUtil import com.junkfood.seal.util.FileUtil.getCookiesFile import com.junkfood.seal.util.PLAYLIST import com.junkfood.seal.util.PreferenceStrings import com.junkfood.seal.util.PreferenceUtil import com.junkfood.seal.util.PreferenceUtil.getBoolean import com.junkfood.seal.util.PreferenceUtil.getInt import com.junkfood.seal.util.PreferenceUtil.getString import com.junkfood.seal.util.PreferenceUtil.updateBoolean import com.junkfood.seal.util.PreferenceUtil.updateInt import com.junkfood.seal.util.PreferenceUtil.updateString import com.junkfood.seal.util.SORTING_FIELDS import com.junkfood.seal.util.SUBTITLE import com.junkfood.seal.util.TEMPLATE_ID import com.junkfood.seal.util.THUMBNAIL import com.junkfood.seal.util.USE_PREVIOUS_SELECTION import com.junkfood.seal.util.VIDEO_FORMAT import com.junkfood.seal.util.VIDEO_QUALITY import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext private enum class DownloadType { Audio, Video, Playlist, Command, } @Composable private fun DownloadType.label(): String = stringResource( when (this) { DownloadType.Audio -> R.string.audio DownloadType.Video -> R.string.video DownloadType.Command -> R.string.commands DownloadType.Playlist -> R.string.playlist } ) private fun DownloadType.updatePreference() { when (this) { DownloadType.Audio -> { EXTRACT_AUDIO.updateBoolean(true) CUSTOM_COMMAND.updateBoolean(false) } DownloadType.Video -> { EXTRACT_AUDIO.updateBoolean(false) CUSTOM_COMMAND.updateBoolean(false) } DownloadType.Command -> { CUSTOM_COMMAND.updateBoolean(true) } DownloadType.Playlist -> { PLAYLIST.updateBoolean(true) CUSTOM_COMMAND.updateBoolean(false) } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun DownloadSettingDialog( useDialog: Boolean = false, showDialog: Boolean = false, isQuickDownload: Boolean = false, onNavigateToCookieGeneratorPage: (String) -> Unit = {}, onDownloadConfirm: () -> Unit, onDismissRequest: () -> Unit, ) { // val audio by remember { mutableStateOf(EXTRACT_AUDIO.getBoolean()) } var thumbnail by remember { mutableStateOf(THUMBNAIL.getBoolean()) } var subtitle by remember { mutableStateOf(SUBTITLE.getBoolean()) } var formatSelection by FORMAT_SELECTION.booleanState var videoFormatPreference by VIDEO_FORMAT.intState var videoQuality by VIDEO_QUALITY.intState var cookies by COOKIES.booleanState var formatSorting by FORMAT_SORTING.booleanState val downloadTypes = remember(isQuickDownload) { if (isQuickDownload) { DownloadType.entries - DownloadType.Playlist } else { DownloadType.entries } } var selectedType by remember(showDialog) { mutableStateOf( when (DOWNLOAD_TYPE_INITIALIZATION.getInt()) { USE_PREVIOUS_SELECTION -> { if (CUSTOM_COMMAND.getBoolean()) { DownloadType.Command } else if (EXTRACT_AUDIO.getBoolean()) { DownloadType.Audio } else { DownloadType.Video } } else -> { null } } ) } var showAudioSettingsDialog by remember { mutableStateOf(false) } var showVideoQualityDialog by remember { mutableStateOf(false) } var showVideoFormatDialog by remember { mutableStateOf(false) } var showAudioConversionDialog by remember { mutableStateOf(false) } var showFormatSortingDialog by remember { mutableStateOf(false) } var sortingFields by remember(showFormatSortingDialog) { mutableStateOf(SORTING_FIELDS.getString()) } var showTemplateSelectionDialog by remember { mutableStateOf(false) } var showTemplateCreatorDialog by remember { mutableStateOf(false) } var showTemplateEditorDialog by remember { mutableStateOf(false) } var showCookiesDialog by rememberSaveable { mutableStateOf(false) } val cookiesProfiles by DatabaseUtil.getCookiesFlow().collectAsStateWithLifecycle(emptyList()) val template by remember(showTemplateCreatorDialog, showTemplateSelectionDialog, showTemplateEditorDialog) { mutableStateOf(PreferenceUtil.getTemplate()) } val scope = rememberCoroutineScope() val context = LocalContext.current LaunchedEffect(showCookiesDialog) { withContext(Dispatchers.IO) { DownloadUtil.getCookiesContentFromDatabase().getOrNull()?.let { FileUtil.writeContentToFile(it, context.getCookiesFile()) } } } val downloadButtonCallback = { onDismissRequest() onDownloadConfirm() } val sheetContent: @Composable () -> Unit = { Column { DrawerSheetSubtitle(text = stringResource(id = R.string.download_type)) LazyRow(modifier = Modifier.fillMaxWidth()) { items(downloadTypes) { type -> SingleChoiceChip(selected = type == selectedType, label = type.label()) { selectedType = type type.updatePreference() } } } if (!isQuickDownload) { DrawerSheetSubtitle(text = stringResource(id = R.string.format_selection)) Row(modifier = Modifier.horizontalScroll(rememberScrollState())) { SingleChoiceChip( selected = !formatSelection || selectedType == DownloadType.Playlist, onClick = { formatSelection = false FORMAT_SELECTION.updateBoolean(false) }, enabled = selectedType != DownloadType.Command, label = stringResource(id = R.string.auto), ) SingleChoiceChip( selected = formatSelection && selectedType != DownloadType.Playlist, onClick = { formatSelection = true FORMAT_SELECTION.updateBoolean(true) }, enabled = selectedType != DownloadType.Command && selectedType != DownloadType.Playlist, label = stringResource(id = R.string.custom), ) } } DrawerSheetSubtitle( text = stringResource( id = if (selectedType == DownloadType.Command) R.string.template_selection else R.string.format_preference ) ) AnimatedContent( targetState = selectedType, label = "", transitionSpec = { (materialSharedAxisYIn(initialOffsetY = { it / 4 })).togetherWith( fadeOut(tween(durationMillis = 80)) ) }, ) { type -> when (type) { DownloadType.Command -> { LazyRow(modifier = Modifier) { item { ButtonChip( icon = Icons.Outlined.Code, label = template.name, onClick = { showTemplateSelectionDialog = true }, ) } item { ButtonChip( icon = Icons.Outlined.NewLabel, label = stringResource(id = R.string.new_template), onClick = { showTemplateCreatorDialog = true }, ) } item { ButtonChip( icon = Icons.Outlined.Edit, label = stringResource(id = R.string.edit_template, template.name), onClick = { showTemplateEditorDialog = true }, ) } } } else -> { Row( modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()) ) { if (type != DownloadType.Audio) { ButtonChip( onClick = { showVideoFormatDialog = true }, enabled = !formatSorting && type != null, label = PreferenceStrings.getVideoFormatLabel( videoFormatPreference ), icon = Icons.Outlined.VideoFile, iconDescription = stringResource(id = R.string.video_format_preference), ) ButtonChip( label = PreferenceStrings.getVideoResolutionDesc(), icon = Icons.Outlined.HighQuality, enabled = !formatSorting && type != null, iconDescription = stringResource(id = R.string.video_quality), ) { showVideoQualityDialog = true } } ButtonChip( onClick = { showAudioSettingsDialog = true }, enabled = !formatSorting && type != null, label = stringResource(R.string.audio_format), icon = Icons.Outlined.AudioFile, ) val convertToMp3 = stringResource(id = R.string.convert_to, "mp3") val convertToM4a = stringResource(id = R.string.convert_to, "m4a") val notConvert = stringResource(id = R.string.not_convert) if (type == DownloadType.Audio) { val convertAudioLabelText by remember(showAudioConversionDialog, type) { derivedStateOf { if (!AUDIO_CONVERT.getBoolean()) { notConvert } else { val format = AUDIO_CONVERSION_FORMAT.getInt() when (format) { CONVERT_MP3 -> convertToMp3 CONVERT_M4A -> convertToM4a else -> notConvert } } } } ButtonChip( label = convertAudioLabelText, icon = Icons.Outlined.Sync, ) { showAudioConversionDialog = true } } } } } } DrawerSheetSubtitle(text = stringResource(R.string.additional_settings)) Row(modifier = Modifier.horizontalScroll(rememberScrollState())) { if (cookiesProfiles.isNotEmpty()) { VideoFilterChip( selected = cookies, onClick = { if (isQuickDownload) { cookies = !cookies COOKIES.updateBoolean(cookies) } else { showCookiesDialog = true } }, label = stringResource(id = R.string.cookies), ) } if (sortingFields.isNotEmpty()) { FilterChip( modifier = Modifier.padding(horizontal = 4.dp), selected = formatSorting, enabled = selectedType != DownloadType.Command, onClick = { showFormatSortingDialog = true }, label = { Text(text = stringResource(id = R.string.format_sorting)) }, ) } VideoFilterChip( selected = subtitle, enabled = selectedType != DownloadType.Command, onClick = { subtitle = !subtitle SUBTITLE.updateBoolean(subtitle) }, label = stringResource(id = R.string.download_subtitles), ) VideoFilterChip( selected = thumbnail, enabled = selectedType != DownloadType.Command, onClick = { thumbnail = !thumbnail THUMBNAIL.updateBoolean(thumbnail) }, label = stringResource(R.string.create_thumbnail), ) } } } if (showDialog) { @Composable fun SheetContent(onDismissRequest: () -> Unit) { Column(modifier = Modifier.verticalScroll(rememberScrollState())) { Icon( modifier = Modifier.align(Alignment.CenterHorizontally), imageVector = Icons.Outlined.DoneAll, contentDescription = null, ) Text( text = stringResource(R.string.settings_before_download), style = MaterialTheme.typography.headlineSmall, modifier = Modifier.align(Alignment.CenterHorizontally).padding(vertical = 16.dp), maxLines = 2, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, ) sheetContent() val state = rememberLazyListState() LazyRow( modifier = Modifier.fillMaxWidth().padding(top = 24.dp), horizontalArrangement = Arrangement.End, state = state, verticalAlignment = Alignment.CenterVertically, ) { item { OutlinedButtonWithIcon( modifier = Modifier.padding(horizontal = 12.dp), onClick = onDismissRequest, icon = Icons.Outlined.Cancel, text = stringResource(R.string.cancel), ) } item { FilledButtonWithIcon( onClick = downloadButtonCallback, icon = Icons.Outlined.DownloadDone, text = stringResource(R.string.start_download), enabled = selectedType != null, ) } } } } if (!useDialog) { val useMD2BottomSheet = Build.VERSION.SDK_INT < 30 if (useMD2BottomSheet) { val sheetState = androidx.compose.material.rememberModalBottomSheetState( initialValue = ModalBottomSheetValue.Hidden, skipHalfExpanded = true, ) BackHandler(sheetState.targetValue == ModalBottomSheetValue.Expanded) { scope.launch { sheetState.hide() } } LaunchedEffect(Unit) { sheetState.show() } LaunchedEffect(sheetState.isVisible) { if (sheetState.targetValue == ModalBottomSheetValue.Hidden) { onDismissRequest() } } SealModalBottomSheetM2( sheetState = sheetState, contentPadding = PaddingValues(horizontal = 20.dp), sheetContent = { SheetContent(onDismissRequest = { scope.launch { sheetState.hide() } }) }, ) } else { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val onSheetDismiss: () -> Unit = { scope.launch { sheetState.hide() }.invokeOnCompletion { onDismissRequest() } } SealModalBottomSheet( sheetState = sheetState, contentPadding = PaddingValues(horizontal = 20.dp), onDismissRequest = onDismissRequest, content = { SheetContent(onDismissRequest = onSheetDismiss) }, ) } } else { AlertDialog( onDismissRequest = onDismissRequest, confirmButton = { TextButton(onClick = downloadButtonCallback) { Text(text = stringResource(R.string.start_download)) } }, dismissButton = { DismissButton { onDismissRequest() } }, icon = { Icon(imageVector = Icons.Outlined.DoneAll, contentDescription = null) }, title = { Text( stringResource(R.string.settings_before_download), textAlign = TextAlign.Center, ) }, text = { Column(Modifier.verticalScroll(rememberScrollState())) { sheetContent() } }, ) } } if (showAudioSettingsDialog) { // AudioQuickSettingsDialog(onDismissRequest = { showAudioSettingsDialog = false }) } if (showVideoFormatDialog) { VideoFormatDialog( videoFormatPreference = videoFormatPreference, onDismissRequest = { showVideoFormatDialog = false }, onConfirm = { videoFormatPreference = it VIDEO_FORMAT.updateInt(it) }, ) } if (showVideoQualityDialog) { VideoQualityDialog( videoQuality = videoQuality, onDismissRequest = { showVideoQualityDialog = false }, onConfirm = { VIDEO_QUALITY.updateInt(it) videoQuality = it }, ) } if (showTemplateSelectionDialog) { TemplatePickerDialog { showTemplateSelectionDialog = false } } if (showTemplateCreatorDialog) { CommandTemplateDialog( onDismissRequest = { showTemplateCreatorDialog = false }, confirmationCallback = { scope.launch { TEMPLATE_ID.updateInt(it) } }, ) } if (showTemplateEditorDialog) { CommandTemplateDialog( commandTemplate = template, onDismissRequest = { showTemplateEditorDialog = false }, ) } if (showCookiesDialog && cookiesProfiles.isNotEmpty()) { CookiesQuickSettingsDialog( onDismissRequest = { showCookiesDialog = false }, onConfirm = {}, cookieProfiles = cookiesProfiles, onCookieProfileClicked = { onNavigateToCookieGeneratorPage(it.url) }, isCookiesEnabled = cookies, onCookiesToggled = { cookies = it COOKIES.updateBoolean(cookies) }, ) } if (showAudioConversionDialog) { AudioConversionQuickSettingsDialog(onDismissRequest = { showAudioConversionDialog = false }) } if (showFormatSortingDialog) { FormatSortingDialog( fields = sortingFields, showSwitch = true, toggleableValue = formatSorting, onSwitchChecked = { formatSorting = it FORMAT_SORTING.updateBoolean(it) }, onImport = { sortingFields = DownloadUtil.DownloadPreferences.createFromPreferences().toFormatSorter() }, onDismissRequest = { showFormatSortingDialog = false }, onConfirm = { sortingFields = it SORTING_FIELDS.updateString(it) }, ) } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/download/HomePageViewModel.kt ================================================ @file:OptIn(ExperimentalMaterial3Api::class) package com.junkfood.seal.ui.page.download import androidx.compose.material3.ExperimentalMaterial3Api import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.junkfood.seal.App.Companion.applicationScope import com.junkfood.seal.App.Companion.context import com.junkfood.seal.Downloader import com.junkfood.seal.Downloader.State import com.junkfood.seal.Downloader.manageDownloadError import com.junkfood.seal.Downloader.updatePlaylistResult import com.junkfood.seal.R import com.junkfood.seal.util.CUSTOM_COMMAND import com.junkfood.seal.util.DownloadUtil import com.junkfood.seal.util.FORMAT_SELECTION import com.junkfood.seal.util.PLAYLIST import com.junkfood.seal.util.PlaylistResult import com.junkfood.seal.util.PreferenceUtil.getBoolean import com.junkfood.seal.util.ToastUtil import com.junkfood.seal.util.VideoInfo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch // TODO: Refactoring for introducing multitasking and download queue management class HomePageViewModel : ViewModel() { private val mutableViewStateFlow = MutableStateFlow(ViewState()) val viewStateFlow = mutableViewStateFlow.asStateFlow() val videoInfoFlow = MutableStateFlow(VideoInfo()) data class ViewState( val showPlaylistSelectionDialog: Boolean = false, val url: String = "", val showFormatSelectionPage: Boolean = false, val isUrlSharingTriggered: Boolean = false, ) fun updateUrl(url: String, isUrlSharingTriggered: Boolean = false) = mutableViewStateFlow.update { it.copy(url = url, isUrlSharingTriggered = isUrlSharingTriggered) } fun startDownloadVideo() { val url = viewStateFlow.value.url Downloader.clearErrorState() if (CUSTOM_COMMAND.getBoolean()) { applicationScope.launch(Dispatchers.IO) { DownloadUtil.executeCommandInBackground(url) } return } if (!Downloader.isDownloaderAvailable()) return if (url.isBlank()) { ToastUtil.makeToast(context.getString(R.string.url_empty)) return } if (PLAYLIST.getBoolean()) { viewModelScope.launch(Dispatchers.IO) { parsePlaylistInfo(url) } return } if (FORMAT_SELECTION.getBoolean()) { viewModelScope.launch(Dispatchers.IO) { fetchInfoForFormatSelection(url) } return } Downloader.getInfoAndDownload(url) } private fun fetchInfoForFormatSelection(url: String) { Downloader.updateState(State.FetchingInfo) DownloadUtil.fetchVideoInfoFromUrl(url = url) .onSuccess { showFormatSelectionPageOrDownload(it) } .onFailure { manageDownloadError(th = it, url = url, isFetchingInfo = true, isTaskAborted = true) } Downloader.updateState(State.Idle) } private fun parsePlaylistInfo(url: String): Unit = Downloader.run { if (!isDownloaderAvailable()) return clearErrorState() updateState(State.FetchingInfo) DownloadUtil.getPlaylistOrVideoInfo(url) .onSuccess { info -> updateState(State.Idle) when (info) { is PlaylistResult -> { showPlaylistPage(info) } is VideoInfo -> { if (FORMAT_SELECTION.getBoolean()) { showFormatSelectionPageOrDownload(info) } else if (isDownloaderAvailable()) { downloadVideoWithInfo(info = info) } } } } .onFailure { manageDownloadError( th = it, url = url, isFetchingInfo = true, isTaskAborted = true, ) } } private fun showPlaylistPage(playlistResult: PlaylistResult) { updatePlaylistResult(playlistResult) mutableViewStateFlow.update { it.copy(showPlaylistSelectionDialog = true) } } private fun showFormatSelectionPageOrDownload(info: VideoInfo) { if (info.format.isNullOrEmpty()) Downloader.downloadVideoWithInfo(info) else { videoInfoFlow.update { info } mutableViewStateFlow.update { it.copy(showFormatSelectionPage = true) } } } fun hidePlaylistDialog() { mutableViewStateFlow.update { it.copy(showPlaylistSelectionDialog = false) } } fun hideFormatPage() { mutableViewStateFlow.update { it.copy(showFormatSelectionPage = false) } } fun onShareIntentConsumed() { mutableViewStateFlow.update { it.copy(isUrlSharingTriggered = false) } } companion object { private const val TAG = "DownloadViewModel" } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/download/MeteredNetworkDialog.kt ================================================ package com.junkfood.seal.ui.page.download import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.SignalCellularConnectedNoInternet4Bar import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.junkfood.seal.R import com.junkfood.seal.ui.component.BottomButtonShape import com.junkfood.seal.ui.component.MiddleButtonShape import com.junkfood.seal.ui.component.SealDialogButtonVariant import com.junkfood.seal.ui.component.SealDialogVariant import com.junkfood.seal.ui.component.TopButtonShape @Composable @Preview fun MeteredNetworkDialog( onDismissRequest: () -> Unit = {}, onAllowOnceConfirm: () -> Unit = {}, onAllowAlwaysConfirm: () -> Unit = {}, ) { SealDialogVariant( onDismissRequest = onDismissRequest, icon = { Icon( imageVector = Icons.Outlined.SignalCellularConnectedNoInternet4Bar, contentDescription = null, ) }, // text = { // Text( // text = stringResource(id = R.string.download_disabled_with_cellular), // modifier = Modifier.padding(horizontal = 24.dp) // ) // }, title = { Text(text = stringResource(id = R.string.download_with_cellular_request)) }, buttons = { SealDialogButtonVariant( text = stringResource(id = R.string.allow_always), shape = TopButtonShape, ) { onAllowAlwaysConfirm() } SealDialogButtonVariant( text = stringResource(id = R.string.allow_once), shape = MiddleButtonShape, ) { onAllowOnceConfirm() } SealDialogButtonVariant( text = stringResource(id = R.string.dont_allow), shape = BottomButtonShape, ) { onDismissRequest() } }, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/download/NotificationPermissionDialog.kt ================================================ package com.junkfood.seal.ui.page.download import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.NotificationsActive import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.junkfood.seal.R @Composable @Preview fun NotificationPermissionDialog( onDismissRequest: () -> Unit = {}, onPermissionGranted: () -> Unit = {}, ) { AlertDialog( onDismissRequest = onDismissRequest, icon = { Icon(imageVector = Icons.Outlined.NotificationsActive, contentDescription = null) }, text = { Text(text = stringResource(id = R.string.enable_notifications_desc)) }, title = { Text(text = stringResource(id = R.string.enable_notifications)) }, confirmButton = { Button(onClick = onPermissionGranted) { Text(text = stringResource(id = R.string.okay)) } }, dismissButton = { OutlinedButton(onClick = onDismissRequest) { Text(text = stringResource(id = R.string.disable)) } }, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/download/PlaylistSelectionDialog.kt ================================================ @file:OptIn(ExperimentalMaterial3Api::class) package com.junkfood.seal.ui.page.download import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.PlaylistAdd import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.core.text.isDigitsOnly import com.junkfood.seal.R import com.junkfood.seal.ui.component.ConfirmButton import com.junkfood.seal.ui.component.DismissButton import com.junkfood.seal.util.PlaylistResult import com.junkfood.seal.util.ToastUtil import com.junkfood.seal.util.isNumberInRange @OptIn(ExperimentalComposeUiApi::class) @Composable fun PlaylistSelectionDialog( playlistInfo: PlaylistResult, onDismissRequest: () -> Unit = {}, onConfirm: (IntRange) -> Unit = {}, ) { val playlistCount = playlistInfo.entries?.size ?: 0 var from by remember { mutableStateOf(1.toString()) } var to by remember { mutableStateOf(playlistCount.toString()) } var error by remember { mutableStateOf(false) } val (item1, item2) = remember { FocusRequester.createRefs() } val onDone: () -> Unit = { error = !from.isNumberInRange(1, playlistCount) or !to.isNumberInRange(1, playlistCount) || from.toInt() > to.toInt() if (error) ToastUtil.makeToast(R.string.invalid_index_range) else { onConfirm(from.toInt()..to.toInt()) onDismissRequest() } } AlertDialog( onDismissRequest = { onDismissRequest() }, icon = { Icon(Icons.Outlined.PlaylistAdd, null) }, title = { Text(stringResource(R.string.download_range_selection)) }, text = { Column { Text( text = stringResource(R.string.download_range_desc) .format(1, playlistCount, playlistInfo.title) ) Row(modifier = Modifier.padding(top = 12.dp)) { Column(modifier = Modifier.weight(1f).padding(end = 6.dp)) { OutlinedTextField( modifier = Modifier.focusable() .focusProperties { next = item2 } .focusRequester(item1), value = from, onValueChange = { if (it.isDigitsOnly()) from = it error = false }, label = { Text(stringResource(R.string.from)) }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.NumberPassword, imeAction = ImeAction.Next, ), singleLine = true, isError = error, ) } Column(modifier = Modifier.weight(1f).padding(start = 6.dp)) { OutlinedTextField( modifier = Modifier.focusable() .focusProperties { previous = item1 } .focusRequester(item2), value = to, onValueChange = { if (it.isDigitsOnly()) to = it error = false }, label = { Text(stringResource(R.string.to)) }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.NumberPassword, imeAction = ImeAction.Done, ), keyboardActions = KeyboardActions(onDone = { onDone() }), singleLine = true, isError = error, ) } } } }, dismissButton = { DismissButton { onDismissRequest() } }, confirmButton = { ConfirmButton(onClick = onDone) }, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/download/VideoSectionSlider.kt ================================================ package com.junkfood.seal.ui.page.download import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowRight import androidx.compose.material.icons.outlined.ContentCut import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RangeSlider import androidx.compose.material3.RangeSliderState import androidx.compose.material3.SliderColors import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.core.text.isDigitsOnly import com.junkfood.seal.R import com.junkfood.seal.ui.component.ConfirmButton import com.junkfood.seal.ui.component.DismissButton import com.junkfood.seal.ui.component.SealDialog import com.junkfood.seal.ui.component.SealTextField import com.junkfood.seal.ui.component.TextButtonWithIcon import com.junkfood.seal.util.isNumberInRange import com.junkfood.seal.util.toDurationText import com.junkfood.seal.util.toIntRange import kotlin.math.roundToInt private const val TAG = "VideoSectionSlider" @OptIn(ExperimentalMaterial3Api::class) @Composable fun CustomRangeSlider( state: RangeSliderState, modifier: Modifier = Modifier, enabled: Boolean = true, colors: SliderColors = SliderDefaults.colors(), startInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, endInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, thumbSize: DpSize = DpSize(4.dp, 24.dp), ) { RangeSlider( modifier = modifier, state = state, startInteractionSource = startInteractionSource, endInteractionSource = endInteractionSource, startThumb = { Box(modifier = Modifier) { SliderDefaults.Thumb( modifier = Modifier.align(Alignment.Center), interactionSource = startInteractionSource, colors = colors, enabled = enabled, thumbSize = thumbSize, ) } }, endThumb = { Box(modifier = Modifier) { SliderDefaults.Thumb( modifier = Modifier.align(Alignment.Center), interactionSource = startInteractionSource, colors = colors, enabled = enabled, thumbSize = thumbSize, ) } }, track = { sliderState -> SliderDefaults.Track(colors = colors, enabled = enabled, rangeSliderState = sliderState) }, ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun VideoSelectionSlider( modifier: Modifier = Modifier, state: RangeSliderState, onDurationClick: () -> Unit, onDiscard: () -> Unit, ) { val startText by remember(state.activeRangeStart) { mutableStateOf(state.activeRangeStart.roundToInt().toDurationText()) } val endText by remember(state.activeRangeEnd) { mutableStateOf(state.activeRangeEnd.roundToInt().toDurationText()) } Column(modifier = modifier) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { CustomRangeSlider( modifier = Modifier.weight(1f).padding(horizontal = 12.dp), state = state, ) } Row( modifier = Modifier.padding(bottom = 16.dp).align(Alignment.End), verticalAlignment = Alignment.CenterVertically, ) { Box( modifier = Modifier.height(40.dp) .clip(CircleShape) .clickable( onClick = onDurationClick, onClickLabel = stringResource(id = R.string.edit), ) ) { Text( text = "$startText / $endText", style = MaterialTheme.typography.labelLarge, modifier = Modifier.align(Alignment.Center).padding(horizontal = 12.dp), ) } Spacer(modifier = Modifier.weight(1f)) TextButtonWithIcon( onClick = onDiscard, icon = Icons.Outlined.Delete, text = stringResource(id = R.string.discard), contentColor = MaterialTheme.colorScheme.error, ) } } } @Composable fun VideoClipDialog( onDismissRequest: () -> Unit, initialValue: ClosedFloatingPointRange, valueRange: ClosedFloatingPointRange, onConfirm: (ClosedFloatingPointRange) -> Unit, ) { var fromMin by remember { mutableStateOf( TextFieldValue( "%02d".format(initialValue.start.roundToInt() / 60), selection = TextRange(Int.MAX_VALUE), ) ) } var toMin by remember { mutableStateOf( TextFieldValue( "%02d".format(initialValue.endInclusive.roundToInt() / 60), selection = TextRange(Int.MAX_VALUE), ) ) } var fromSec by remember { mutableStateOf( TextFieldValue( "%02d".format(initialValue.start.roundToInt() % 60), selection = TextRange(Int.MAX_VALUE), ) ) } var toSec by remember { mutableStateOf( TextFieldValue( "%02d".format(initialValue.endInclusive.roundToInt() % 60), selection = TextRange(Int.MAX_VALUE), ) ) } var error by remember(fromMin.text, toMin, fromSec, toSec) { mutableStateOf(false) } val valueIntRange = valueRange.toIntRange() val start = stringResource(id = R.string.clip_start) val end = stringResource(id = R.string.clip_end) val minute = "," + stringResource(id = R.string.minute) val second = "," + stringResource(id = R.string.second) fun onDone() { val startTime = convertToSecs(fromMin.text, fromSec.text) val endTime = convertToSecs(toMin.text, toSec.text) if ( startTime != -1 && endTime != -1 && startTime < endTime && valueIntRange.contains(startTime) && valueIntRange.contains(endTime) ) { onConfirm((startTime.toFloat())..endTime.toFloat()) onDismissRequest() } else error = true } SealDialog( onDismissRequest = onDismissRequest, title = { Text(stringResource(id = R.string.clip_video)) }, icon = { Icon(Icons.Outlined.ContentCut, null) }, confirmButton = { ConfirmButton { onDone() } }, dismissButton = { DismissButton { onDismissRequest() } }, text = { Column() { Row( modifier = Modifier.padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f).padding(end = 6.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { SealTextField( modifier = Modifier.weight(1f).semantics { contentDescription = start + minute }, value = fromMin, onValueChange = { if (it.text.isDigitsOnly()) fromMin = it }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.NumberPassword, imeAction = ImeAction.Next, ), singleLine = true, isError = error, ) Text( modifier = Modifier.padding(horizontal = 4.dp), text = ":", style = MaterialTheme.typography.labelLarge, ) SealTextField( modifier = Modifier.weight(1f).semantics { contentDescription = start + second }, value = fromSec, onValueChange = { if (it.text.isDigitsOnly()) fromSec = it }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.NumberPassword, imeAction = ImeAction.Next, ), singleLine = true, isError = error, ) } } Icon( imageVector = Icons.AutoMirrored.Outlined.ArrowRight, contentDescription = null, ) Row( modifier = Modifier.weight(1f).padding(start = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { SealTextField( modifier = Modifier.weight(1f).semantics { contentDescription = end + minute }, value = toMin, onValueChange = { if (it.text.isDigitsOnly()) toMin = it }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.NumberPassword, imeAction = ImeAction.Next, ), singleLine = true, isError = error, ) Text( modifier = Modifier.padding(horizontal = 4.dp), text = ":", style = MaterialTheme.typography.labelLarge, ) SealTextField( modifier = Modifier.weight(1f).semantics { contentDescription = end + second }, value = toSec, onValueChange = { if (it.text.isDigitsOnly()) toSec = it }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.NumberPassword, imeAction = ImeAction.Done, ), keyboardActions = KeyboardActions(onDone = { onDone() }), singleLine = true, isError = error, ) } } } }, ) } @Composable @Preview fun VideoClipDialogPreview() { VideoClipDialog( onDismissRequest = {}, initialValue = 0f..560f, valueRange = 0f..660f, onConfirm = {}, ) } @OptIn(ExperimentalMaterial3Api::class) @Composable @Preview fun SliderPreview() { val time = 3700 var valueRange by remember { mutableStateOf(0f..time.toFloat()) } var shouldUpdate by remember { mutableStateOf(false) } val state = remember { RangeSliderState( activeRangeStart = valueRange.start, activeRangeEnd = valueRange.endInclusive, valueRange = 0f..time.toFloat(), onValueChangeFinished = { shouldUpdate = true }, ) } DisposableEffect(shouldUpdate) { valueRange = state.activeRangeStart..state.activeRangeEnd onDispose { shouldUpdate = false } } Surface() { Column { Text(text = "${valueRange.toIntRange()}") VideoSelectionSlider(state = state, onDiscard = {}, onDurationClick = {}) } } } private fun convertToSecs(min: String, sec: String): Int { return if (sec.isNumberInRange(0, 60)) { if (min.isNumberInRange(0, Int.MAX_VALUE)) { min.toInt() * 60 + sec.toInt() } else -1 } else -1 } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/downloadv2/ActionSheet.kt ================================================ package com.junkfood.seal.ui.page.downloadv2 import android.content.res.Configuration import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyRow import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.OpenInNew import androidx.compose.material.icons.automirrored.outlined.TextSnippet import androidx.compose.material.icons.outlined.AudioFile import androidx.compose.material.icons.outlined.Cancel import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material.icons.outlined.FileDownload import androidx.compose.material.icons.outlined.Image import androidx.compose.material.icons.outlined.Link import androidx.compose.material.icons.outlined.RestartAlt import androidx.compose.material.icons.outlined.VideoFile import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Share import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.junkfood.seal.R import com.junkfood.seal.download.FakeDownloaderV2 import com.junkfood.seal.download.Task import com.junkfood.seal.download.Task.* import com.junkfood.seal.download.Task.DownloadState.Canceled import com.junkfood.seal.download.Task.DownloadState.Completed import com.junkfood.seal.download.Task.DownloadState.Error import com.junkfood.seal.download.Task.DownloadState.FetchingInfo import com.junkfood.seal.download.Task.DownloadState.Idle import com.junkfood.seal.download.Task.DownloadState.ReadyWithInfo import com.junkfood.seal.download.Task.DownloadState.Running import com.junkfood.seal.ui.common.LocalFixedColorRoles import com.junkfood.seal.ui.component.ActionSheetItem import com.junkfood.seal.ui.component.ActionSheetPrimaryButton import com.junkfood.seal.ui.component.SealModalBottomSheet import com.junkfood.seal.ui.page.downloadv2.configure.PreferencesMock import com.junkfood.seal.ui.theme.ErrorTonalPalettes import com.junkfood.seal.ui.theme.SealTheme import com.junkfood.seal.util.Format import com.junkfood.seal.util.toBitrateText import com.junkfood.seal.util.toDurationText import com.junkfood.seal.util.toFileSizeText import com.junkfood.seal.util.toLocalizedString import kotlinx.coroutines.Job import kotlinx.coroutines.launch @Composable private fun ShareButton(modifier: Modifier = Modifier, onClick: () -> Unit) { ActionSheetPrimaryButton( modifier = modifier, containerColor = LocalFixedColorRoles.current.secondaryFixed, contentColor = LocalFixedColorRoles.current.onSecondaryFixedVariant, imageVector = Icons.Rounded.Share, text = stringResource(R.string.share), onClick = onClick, ) } @Composable private fun PlayButton(modifier: Modifier = Modifier, onClick: () -> Unit) { ActionSheetPrimaryButton( modifier = modifier, containerColor = LocalFixedColorRoles.current.primaryFixed, contentColor = LocalFixedColorRoles.current.onPrimaryFixedVariant, imageVector = Icons.Rounded.PlayArrow, text = stringResource(R.string.open_file), onClick = onClick, ) } @Composable private fun ResumeButton(modifier: Modifier = Modifier, onClick: () -> Unit) { ActionSheetPrimaryButton( modifier = modifier, containerColor = LocalFixedColorRoles.current.tertiaryFixed, contentColor = LocalFixedColorRoles.current.onTertiaryFixedVariant, imageVector = Icons.Outlined.RestartAlt, text = stringResource(R.string.resume), onClick = onClick, ) } @Composable private fun ErrorReportButton(modifier: Modifier = Modifier, onClick: () -> Unit) { ActionSheetPrimaryButton( modifier = modifier, containerColor = ErrorTonalPalettes.accent1(80.0), contentColor = ErrorTonalPalettes.accent1(10.0), imageVector = Icons.Outlined.ErrorOutline, text = stringResource(R.string.copy_error_report), onClick = onClick, ) } @Composable private fun DeleteButton(modifier: Modifier = Modifier, onClick: () -> Unit) { ActionSheetPrimaryButton( modifier = modifier, containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onSurface, imageVector = Icons.Outlined.Delete, outlineColor = MaterialTheme.colorScheme.outlineVariant, text = stringResource(R.string.delete), onClick = onClick, ) } @Composable private fun CancelButton(modifier: Modifier = Modifier, onClick: () -> Unit) { ActionSheetPrimaryButton( modifier = modifier, containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, contentColor = MaterialTheme.colorScheme.onSurface, imageVector = Icons.Outlined.Cancel, text = stringResource(R.string.cancel), onClick = onClick, ) } @Composable private fun DownloadLogButton(modifier: Modifier = Modifier, onClick: () -> Unit) { ActionSheetPrimaryButton( modifier = modifier, containerColor = LocalFixedColorRoles.current.secondaryFixed, contentColor = LocalFixedColorRoles.current.onSecondaryFixedVariant, imageVector = Icons.AutoMirrored.Outlined.TextSnippet, text = stringResource(R.string.show_logs), onClick = onClick, ) } @Composable private fun CopyURLButton(modifier: Modifier = Modifier, onClick: () -> Unit) { ActionSheetPrimaryButton( modifier = modifier, containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onSurface, outlineColor = MaterialTheme.colorScheme.outlineVariant, imageVector = Icons.Outlined.ContentCopy, text = stringResource(R.string.copy_link), onClick = onClick, ) } @Composable private fun OpenVideoURLButton(modifier: Modifier = Modifier, onClick: () -> Unit) { ActionSheetPrimaryButton( modifier = modifier, containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onSurface, outlineColor = MaterialTheme.colorScheme.outlineVariant, imageVector = Icons.AutoMirrored.Outlined.OpenInNew, text = stringResource(R.string.open_url), onClick = onClick, ) } @Composable private fun OpenThumbnailURLButton(modifier: Modifier = Modifier, onClick: () -> Unit) { ActionSheetPrimaryButton( modifier = modifier, containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onSurface, outlineColor = MaterialTheme.colorScheme.outlineVariant, imageVector = Icons.Outlined.Image, text = stringResource(R.string.thumbnail), onClick = onClick, ) } @Composable fun Title(imageModel: Any?, title: String, author: String, downloadState: DownloadState) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically, ) { /* AsyncImageImpl( model = imageModel, modifier = Modifier.height(64.dp).aspectRatio(16f / 9f, matchHeightConstraintsFirst = true), contentDescription = null, contentScale = ContentScale.Crop, )*/ // Spacer(Modifier.width(12.dp)) Column(modifier = Modifier.height(IntrinsicSize.Min)) { Column(Modifier) { Text(text = title, style = MaterialTheme.typography.titleSmall) Spacer(Modifier.height(2.dp)) Text( text = author, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Spacer(modifier = Modifier.weight(1f)) Spacer(Modifier.height(8.dp)) ListItemStateText(downloadState = downloadState) } } } @Composable fun SheetContent( task: Task, viewState: ViewState, downloadState: DownloadState, onDismissRequest: () -> Unit, onActionPost: (Task, UiAction) -> Unit, ) { LazyColumn { item { Title( imageModel = viewState.thumbnailUrl, title = viewState.title, author = viewState.uploader, downloadState = downloadState, ) } item { LazyRow( modifier = Modifier.padding(top = 12.dp, bottom = 24.dp), contentPadding = PaddingValues(horizontal = 4.dp), ) { ActionButtons( task = task, downloadState = downloadState, viewState = viewState, onDismissRequest = onDismissRequest, onActionPost = onActionPost, ) } } item { ActionSheetInfo(task = task, viewState = viewState) } } } fun LazyListScope.ActionButtons( task: Task, downloadState: DownloadState, viewState: ViewState, onDismissRequest: () -> Unit, onActionPost: (Task, UiAction) -> Unit, ) { when (downloadState) { is Canceled -> { item(key = "ResumeButton") { ResumeButton(modifier = Modifier.animateItem()) { onActionPost(task, UiAction.Resume) onDismissRequest() } } } is Completed -> { item(key = "PlayButton") { PlayButton(modifier = Modifier.animateItem()) { onActionPost(task, UiAction.OpenFile(downloadState.filePath)) onDismissRequest() } } item(key = "ShareButton") { ShareButton(modifier = Modifier.animateItem()) { onActionPost(task, UiAction.ShareFile(downloadState.filePath)) } } } is Error -> { item(key = "ResumeButton") { ResumeButton(modifier = Modifier.animateItem()) { onActionPost(task, UiAction.Resume) onDismissRequest() } } item(key = "ErrorReportButton") { ErrorReportButton(modifier = Modifier.animateItem()) { onActionPost(task, UiAction.CopyErrorReport(downloadState.throwable)) } } } is FetchingInfo, ReadyWithInfo, Idle, is Running -> { item(key = "CancelButton") { CancelButton(modifier = Modifier.animateItem()) { onActionPost(task, UiAction.Cancel) onDismissRequest() } } } } if (downloadState is DownloadState.Restartable || downloadState is Completed) { item(key = "DeleteButton") { DeleteButton(modifier = Modifier.animateItem()) { onActionPost(task, UiAction.Delete) onDismissRequest() } } } item(key = "CopyURLButton") { CopyURLButton(modifier = Modifier.animateItem()) { onActionPost(task, UiAction.CopyVideoURL) } } item(key = "OpenVideoURLButton") { OpenVideoURLButton(modifier = Modifier.animateItem()) { onActionPost(task, UiAction.OpenVideoURL(viewState.url)) } } if (!viewState.thumbnailUrl.isNullOrEmpty()) { item(key = "OpenThumbnailURLButton") { OpenThumbnailURLButton(modifier = Modifier.animateItem()) { onActionPost(task, UiAction.OpenThumbnailURL(viewState.thumbnailUrl)) } } } } @OptIn(ExperimentalMaterial3Api::class) @Preview @Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun SheetPreview() { val sheetState = with(LocalDensity.current) { SheetState( initialValue = SheetValue.Expanded, skipPartiallyExpanded = true, velocityThreshold = { 56.dp.toPx() }, positionalThreshold = { 125.dp.toPx() }, ) } var downloadState: DownloadState by remember { mutableStateOf(Running(Job(), "", 0.58f)) } val fakeStateList = listOf( Running(Job(), "", 0.58f), Error(throwable = Throwable(), RestartableAction.Download), FetchingInfo(Job(), ""), Canceled(RestartableAction.Download), ReadyWithInfo, Idle, Completed(null), ) LaunchedEffect(Unit) { while (true) { fakeStateList.forEach { downloadState = it kotlinx.coroutines.delay(1000) } } } val context = LocalContext.current val downloader = FakeDownloaderV2 val scope = rememberCoroutineScope() val viewState = ViewState( title = "video title looooooooooooooooooooooooooooong title sample", uploader = "author loooooooooooooooooooooonggggggggggggggggg", videoFormats = listOf( Format( vcodec = "vp9", resolution = "1280x720", vbr = 129400.0, fileSize = 11451400.0, ) ), audioOnlyFormats = listOf(Format(acodec = "mp4a", abr = 129.0, fileSize = 114514.0)), ) SealTheme { Surface() { SealModalBottomSheet( contentPadding = PaddingValues(), onDismissRequest = {}, sheetState = sheetState, ) { SheetContent( task = Task(url = "https://www.example.com", preferences = PreferencesMock), viewState = viewState, downloadState = downloadState, onDismissRequest = { scope.launch { sheetState.hide() } }, ) { task, action -> } } } } } @Composable fun ActionSheetInfo(modifier: Modifier = Modifier, task: Task, viewState: ViewState) { with(viewState) { Column(modifier = modifier) { HorizontalDivider() Text( stringResource(R.string.media_info), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(start = 16.dp, top = 24.dp, bottom = 8.dp), ) ActionSheetItem( text = { Text( task.timeCreated.toLocalizedString(), style = MaterialTheme.typography.titleSmall, ) Text( "${duration.toDurationText()} · ${fileSizeApprox.toFileSizeText()}", style = MaterialTheme.typography.bodySmall, ) }, leadingIcon = { Icon(imageVector = Icons.Outlined.FileDownload, contentDescription = null) }, ) videoFormats?.forEachIndexed { _index, fmt -> val index = _index + 1 val fileSizeText = (fmt.fileSize ?: fmt.fileSizeApprox).toFileSizeText() val bitRateText = fmt.vbr.toBitrateText() val codecText = fmt.vcodec?.substringBefore(delimiter = ".") ?: "" val title = "${stringResource(R.string.video)} #$index: ${fmt.formatNote}" val details = listOf(codecText, fmt.resolution, bitRateText, fileSizeText) .filterNot { it.isNullOrBlank() } .joinToString(separator = " · ") ActionSheetItem( text = { Text(title, style = MaterialTheme.typography.titleSmall) Text(details, style = MaterialTheme.typography.bodySmall) }, leadingIcon = { Icon(imageVector = Icons.Outlined.VideoFile, contentDescription = null) }, ) } val audioFormats: List = buildList { videoFormats?.filter { it.containsAudio() }?.let { addAll(it) } audioOnlyFormats?.let { addAll(it) } } audioFormats.forEachIndexed { _index, fmt -> val index = _index + 1 val fileSizeText = (fmt.fileSize ?: fmt.fileSizeApprox).toFileSizeText() val bitRateText = fmt.abr.toBitrateText() val codecText = fmt.acodec?.substringBefore(delimiter = ".") ?: "" val title = "${stringResource(R.string.audio)} #$index: ${fmt.formatNote}" val details = listOf(codecText, bitRateText, fileSizeText) .filterNot { it.isBlank() } .joinToString(separator = " · ") ActionSheetItem( text = { Text(title, style = MaterialTheme.typography.titleSmall) Text(details, style = MaterialTheme.typography.bodySmall) }, leadingIcon = { Icon(imageVector = Icons.Outlined.AudioFile, contentDescription = null) }, ) } ActionSheetItem( text = { Text(text = extractorKey, style = MaterialTheme.typography.titleSmall) Text(text = url, style = MaterialTheme.typography.bodySmall) }, leadingIcon = { Icon(imageVector = Icons.Outlined.Link, contentDescription = null) }, ) } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/downloadv2/DownloadPageV2.kt ================================================ package com.junkfood.seal.ui.page.downloadv2 import android.content.Intent import android.content.res.Configuration import androidx.compose.animation.core.AnimationState import androidx.compose.animation.core.animateTo import androidx.compose.animation.rememberSplineBasedDecay import androidx.compose.foundation.Image import androidx.compose.foundation.LocalOverscrollFactory import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.List import androidx.compose.material.icons.outlined.FileDownload import androidx.compose.material.icons.outlined.GridView import androidx.compose.material.icons.outlined.Menu import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.constraintlayout.compose.ConstraintLayout import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.junkfood.seal.R import com.junkfood.seal.download.DownloaderV2 import com.junkfood.seal.download.Task import com.junkfood.seal.download.Task.DownloadState.Canceled import com.junkfood.seal.download.Task.DownloadState.Completed import com.junkfood.seal.download.Task.DownloadState.Error import com.junkfood.seal.download.Task.DownloadState.FetchingInfo import com.junkfood.seal.download.Task.DownloadState.Idle import com.junkfood.seal.download.Task.DownloadState.ReadyWithInfo import com.junkfood.seal.download.Task.DownloadState.Running import com.junkfood.seal.ui.common.HapticFeedback.slightHapticFeedback import com.junkfood.seal.ui.common.LocalDarkTheme import com.junkfood.seal.ui.common.LocalFixedColorRoles import com.junkfood.seal.ui.common.LocalWindowWidthState import com.junkfood.seal.ui.component.SealModalBottomSheet import com.junkfood.seal.ui.component.SelectionGroupDefaults import com.junkfood.seal.ui.component.SelectionGroupItem import com.junkfood.seal.ui.component.SelectionGroupRow import com.junkfood.seal.ui.page.downloadv2.configure.DownloadDialogViewModel.Action import com.junkfood.seal.ui.page.downloadv2.configure.Config import com.junkfood.seal.ui.page.downloadv2.configure.DownloadDialog import com.junkfood.seal.ui.page.downloadv2.configure.DownloadDialogViewModel import com.junkfood.seal.ui.page.downloadv2.configure.FormatPage import com.junkfood.seal.ui.page.downloadv2.configure.PlaylistSelectionPage import com.junkfood.seal.ui.page.downloadv2.configure.PreferencesMock import com.junkfood.seal.ui.svg.DynamicColorImageVectors import com.junkfood.seal.ui.svg.drawablevectors.download import com.junkfood.seal.ui.theme.SealTheme import com.junkfood.seal.util.DownloadUtil import com.junkfood.seal.util.FileUtil import com.junkfood.seal.util.getErrorReport import com.junkfood.seal.util.makeToast import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.koin.compose.koinInject private const val TAG = "DownloadPageV2" enum class Filter { All, Downloading, Canceled, Finished; @Composable @ReadOnlyComposable fun label(): String = when (this) { All -> stringResource(R.string.all) Downloading -> stringResource(R.string.status_downloading) Canceled -> stringResource(R.string.status_canceled) Finished -> stringResource(R.string.status_completed) } fun predict(entry: Pair): Boolean { if (this == All) return true val state = entry.second.downloadState return when (this) { Downloading -> { when (state) { is FetchingInfo, Idle, ReadyWithInfo, is Running -> true else -> false } } Canceled -> { state is Error || state is Task.DownloadState.Canceled } Finished -> { state is Completed } else -> { true } } } } sealed interface UiAction { data class OpenFile(val filePath: String?) : UiAction data class ShareFile(val filePath: String?) : UiAction data class OpenThumbnailURL(val url: String) : UiAction data object CopyVideoURL : UiAction data class OpenVideoURL(val url: String) : UiAction data object Cancel : UiAction data object Delete : UiAction data object Resume : UiAction data class CopyErrorReport(val throwable: Throwable) : UiAction } @OptIn(ExperimentalMaterial3Api::class) @Composable fun DownloadPageV2( modifier: Modifier = Modifier, onMenuOpen: (() -> Unit) = {}, dialogViewModel: DownloadDialogViewModel, downloader: DownloaderV2 = koinInject(), ) { val view = LocalView.current val context = LocalContext.current val scope = rememberCoroutineScope() val clipboardManager = LocalClipboardManager.current val uriHandler = LocalUriHandler.current DownloadPageImplV2( modifier = modifier, taskDownloadStateMap = downloader.getTaskStateMap(), downloadCallback = { view.slightHapticFeedback() dialogViewModel.postAction(Action.ShowSheet()) }, onMenuOpen = onMenuOpen, ) { task, action -> view.slightHapticFeedback() when (action) { UiAction.Cancel -> downloader.cancel(task) UiAction.Delete -> downloader.remove(task) UiAction.Resume -> downloader.restart(task) is UiAction.CopyErrorReport -> { clipboardManager.setText( AnnotatedString(getErrorReport(action.throwable, task.url)) ) context.makeToast(R.string.error_copied) } UiAction.CopyVideoURL -> { clipboardManager.setText(AnnotatedString(task.url)) context.makeToast(R.string.link_copied) } is UiAction.OpenFile -> { action.filePath?.let { FileUtil.openFile(path = it) { context.makeToast(R.string.file_unavailable) } } } is UiAction.OpenThumbnailURL -> { uriHandler.openUri(action.url) } is UiAction.OpenVideoURL -> { uriHandler.openUri(action.url) } is UiAction.ShareFile -> { val shareTitle = context.getString(R.string.share) FileUtil.createIntentForSharingFile(action.filePath)?.let { context.startActivity(Intent.createChooser(it, shareTitle)) } } } } var preferences by remember { mutableStateOf(DownloadUtil.DownloadPreferences.createFromPreferences()) } val sheetValue by dialogViewModel.sheetValueFlow.collectAsStateWithLifecycle() val state by dialogViewModel.sheetStateFlow.collectAsStateWithLifecycle() val selectionState = dialogViewModel.selectionStateFlow.collectAsStateWithLifecycle().value var showDialog by remember { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) LaunchedEffect(sheetValue) { if (sheetValue == DownloadDialogViewModel.SheetValue.Expanded) { showDialog = true } else { launch { sheetState.hide() }.invokeOnCompletion { showDialog = false } } } if (showDialog) { DownloadDialog( state = state, sheetState = sheetState, config = Config(), preferences = preferences, onPreferencesUpdate = { preferences = it }, onActionPost = { dialogViewModel.postAction(it) }, ) } when (selectionState) { is DownloadDialogViewModel.SelectionState.FormatSelection -> FormatPage( state = selectionState, onDismissRequest = { dialogViewModel.postAction(Action.Reset) }, ) is DownloadDialogViewModel.SelectionState.PlaylistSelection -> { PlaylistSelectionPage( state = selectionState, onDismissRequest = { dialogViewModel.postAction(Action.Reset) }, ) } DownloadDialogViewModel.SelectionState.Idle -> {} } } @Composable private operator fun PaddingValues.plus(other: PaddingValues): PaddingValues { val layoutDirection = LocalLayoutDirection.current return PaddingValues( top = calculateTopPadding() + other.calculateTopPadding(), bottom = calculateBottomPadding() + other.calculateBottomPadding(), start = calculateStartPadding(layoutDirection) + other.calculateStartPadding(layoutDirection), end = calculateEndPadding(layoutDirection) + other.calculateEndPadding(layoutDirection), ) } private const val HeaderSpacingDp = 28 @OptIn(ExperimentalMaterial3Api::class) @Composable fun DownloadPageImplV2( modifier: Modifier = Modifier, taskDownloadStateMap: SnapshotStateMap, downloadCallback: () -> Unit = {}, onMenuOpen: (() -> Unit) = {}, onActionPost: (Task, UiAction) -> Unit, ) { var activeFilter by remember { mutableStateOf(Filter.All) } val filteredMap by remember(activeFilter) { derivedStateOf { taskDownloadStateMap.filter { activeFilter.predict(it.toPair()) } } } val scope = rememberCoroutineScope() val context = LocalContext.current val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) var selectedTask by remember { mutableStateOf(null) } val view = LocalView.current fun showActionSheet(task: Task) { view.slightHapticFeedback() scope.launch { selectedTask = task delay(50) sheetState.show() } } LaunchedEffect(selectedTask, taskDownloadStateMap.size) { if (!taskDownloadStateMap.contains(selectedTask)) { selectedTask == null } } Scaffold( modifier = modifier.fillMaxSize().statusBarsPadding(), containerColor = MaterialTheme.colorScheme.surface, floatingActionButton = { FABs(modifier = Modifier, downloadCallback = downloadCallback) }, ) { windowInsetsPadding -> val lazyListState = rememberLazyGridState() val windowWidthSizeClass = LocalWindowWidthState.current val spacerHeight = with(LocalDensity.current) { if (windowWidthSizeClass != WindowWidthSizeClass.Compact) 0f else HeaderSpacingDp.dp.toPx() } var headerOffset by remember { mutableFloatStateOf(spacerHeight) } var isGridView by rememberSaveable { mutableStateOf(true) } Column( modifier = Modifier.fillMaxSize() .then( if (windowWidthSizeClass != WindowWidthSizeClass.Compact) Modifier else Modifier.nestedScroll( connection = TopBarNestedScrollConnection( maxOffset = spacerHeight, flingAnimationSpec = rememberSplineBasedDecay(), offset = { headerOffset }, onOffsetUpdate = { headerOffset = it }, ) ) ) ) { CompositionLocalProvider(LocalOverscrollFactory provides null) { Column(modifier = Modifier.fillMaxWidth()) { Spacer(Modifier.height(with(LocalDensity.current) { headerOffset.toDp() })) Header(onMenuOpen = onMenuOpen, modifier = Modifier.padding(horizontal = 16.dp)) SelectionGroupRow( modifier = Modifier.horizontalScroll(rememberScrollState()) .padding(horizontal = 20.dp) ) { Filter.entries.forEach { filter -> SelectionGroupItem( colors = SelectionGroupDefaults.colors( activeContainerColor = LocalFixedColorRoles.current.tertiaryFixed, activeContentColor = LocalFixedColorRoles.current.onTertiaryFixed, ), selected = activeFilter == filter, onClick = { if (activeFilter == filter) { scope.launch { lazyListState.animateScrollToItem(0) } scope.launch { val initialValue = headerOffset AnimationState(initialValue = initialValue).animateTo( spacerHeight ) { headerOffset = value } } } else { activeFilter = filter } }, ) { Text(filter.label()) } } } Spacer(Modifier.height(8.dp)) if (headerOffset <= 0.1f && spacerHeight > 0f) { HorizontalDivider(thickness = Dp.Hairline) } } LazyVerticalGrid( modifier = Modifier, state = lazyListState, columns = GridCells.Adaptive(240.dp), contentPadding = windowInsetsPadding + PaddingValues(start = 20.dp, end = 20.dp, bottom = 80.dp), horizontalArrangement = Arrangement.spacedBy(24.dp), ) { if (filteredMap.isNotEmpty()) { item(span = { GridItemSpan(maxLineSpan) }) { val videoCount = filteredMap.count { !it.value.viewState.videoFormats.isNullOrEmpty() } SubHeader( modifier = Modifier, videoCount = videoCount, audioCount = filteredMap.size - videoCount, isGridView = isGridView, onToggleView = { isGridView = !isGridView }, onShowMenu = { context.makeToast("Not implemented yet!") }, ) } } if (isGridView) { items( items = filteredMap.toList().sortedBy { (_, state) -> state.downloadState }, key = { (task, _) -> task.id }, ) { (task, state) -> with(state.viewState) { VideoCardV2( modifier = Modifier.padding(bottom = 20.dp).padding(), viewState = this, actionButton = { ActionButton( modifier = Modifier, downloadState = state.downloadState, ) { onActionPost(task, it) } }, stateIndicator = { CardStateIndicator( modifier = Modifier, downloadState = state.downloadState, ) }, onButtonClick = { showActionSheet(task) }, ) } } } else { items( items = filteredMap.toList().sortedBy { (_, state) -> state.downloadState }, key = { (task, _) -> task.id }, span = { GridItemSpan(maxLineSpan) }, ) { (task, state) -> VideoListItem( modifier = Modifier.padding(bottom = 16.dp), viewState = state.viewState, stateIndicator = { ListItemStateText( modifier = Modifier.padding(top = 3.dp), downloadState = state.downloadState, ) }, onButtonClick = { showActionSheet(task) }, ) } } } } } if (filteredMap.isEmpty()) { Box(modifier = Modifier.fillMaxSize()) { DownloadQueuePlaceholder( modifier = Modifier.fillMaxHeight(0.4f).widthIn(max = 360.dp).align(Alignment.Center) ) } } } if (selectedTask != null) { val task = selectedTask!! val (downloadState, _, viewState) = taskDownloadStateMap[task] ?: return SealModalBottomSheet( sheetState = sheetState, contentPadding = PaddingValues(), onDismissRequest = { scope.launch { sheetState.hide() }.invokeOnCompletion { selectedTask = null } }, ) { SheetContent( task = task, downloadState = downloadState, viewState = viewState, onDismissRequest = { scope.launch { sheetState.hide() }.invokeOnCompletion { selectedTask = null } }, onActionPost = onActionPost, ) } } } @Composable fun Header(modifier: Modifier = Modifier, onMenuOpen: () -> Unit = {}) { val windowWidthSizeClass = LocalWindowWidthState.current when (windowWidthSizeClass) { WindowWidthSizeClass.Expanded -> { HeaderExpanded(modifier = modifier) } else -> { HeaderCompact(modifier = modifier, onMenuOpen = onMenuOpen) } } } @Composable private fun HeaderCompact(modifier: Modifier = Modifier, onMenuOpen: () -> Unit) { Row(modifier = modifier.height(64.dp), verticalAlignment = Alignment.CenterVertically) { IconButton(onClick = onMenuOpen, modifier = Modifier) { Icon( imageVector = Icons.Outlined.Menu, contentDescription = stringResource(R.string.show_navigation_drawer), modifier = Modifier, ) } Spacer(modifier = Modifier.width(4.dp)) Text( stringResource(R.string.download_queue), style = MaterialTheme.typography.titleLarge.copy( fontSize = 20.sp, fontWeight = FontWeight.Medium, ), ) } } @Composable private fun HeaderExpanded(modifier: Modifier = Modifier) { Row(modifier = modifier.height(64.dp), verticalAlignment = Alignment.CenterVertically) { Spacer(modifier = Modifier.width(4.dp)) Text( stringResource(R.string.download_queue), style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Medium), ) } Spacer(Modifier.height(4.dp)) } @Composable fun FABs(modifier: Modifier = Modifier, downloadCallback: () -> Unit = {}) { val expanded = LocalWindowWidthState.current != WindowWidthSizeClass.Compact Column(modifier = modifier.padding(6.dp), horizontalAlignment = Alignment.End) { FloatingActionButton( onClick = downloadCallback, content = { if (expanded) { Row( modifier = Modifier.widthIn(min = 80.dp).padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon(Icons.Outlined.FileDownload, contentDescription = null) Spacer(Modifier.width(12.dp)) Text(stringResource(R.string.download)) } } else { Icon( Icons.Outlined.FileDownload, contentDescription = stringResource(R.string.download), ) } }, modifier = Modifier.padding(vertical = 12.dp), ) } } @Composable @Preview private fun DownloadQueuePlaceholder(modifier: Modifier = Modifier) { BoxWithConstraints(modifier = modifier) { ConstraintLayout { val (image, text) = createRefs() val showImage = with(LocalDensity.current) { this@BoxWithConstraints.constraints.maxHeight >= 240.dp.toPx() } if (showImage) { Image( painter = rememberVectorPainter(image = DynamicColorImageVectors.download()), contentDescription = null, modifier = Modifier.fillMaxHeight(0.5f).widthIn(max = 240.dp).constrainAs(image) { top.linkTo(parent.top) bottom.linkTo(parent.bottom) start.linkTo(parent.start) end.linkTo(parent.end) }, ) } else { Spacer(Modifier.height(72.dp).constrainAs(image) { top.linkTo(parent.top) }) } Column( modifier = Modifier.constrainAs(text) { top.linkTo(image.bottom, margin = 36.dp) }, horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = stringResource(R.string.you_ll_find_your_downloads_here), modifier = Modifier.padding(horizontal = 24.dp), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, ) Text( text = stringResource(R.string.download_hint), modifier = Modifier.padding(top = 4.dp).padding(horizontal = 24.dp), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) } } } } @Composable fun SubHeader( modifier: Modifier = Modifier, containerColor: Color = MaterialTheme.colorScheme.run { if (LocalDarkTheme.current.isDarkTheme()) surfaceContainer else surfaceContainerLowest }, videoCount: Int = 0, audioCount: Int = 0, isGridView: Boolean = true, onToggleView: () -> Unit, onShowMenu: () -> Unit, ) { val text = buildString { if (videoCount > 0) { append(pluralStringResource(R.plurals.video_count, videoCount).format(videoCount)) if (audioCount > 0) { append(", ") } } if (audioCount > 0) { append(pluralStringResource(R.plurals.audio_count, audioCount).format(audioCount)) } } Row( modifier = modifier.padding(top = 12.dp, bottom = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Row( modifier = Modifier.padding(start = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Text(text = text, style = MaterialTheme.typography.labelLarge) Spacer(Modifier.width(4.dp)) } Spacer(modifier = Modifier.weight(1f)) FilledIconButton( onClick = onToggleView, modifier = Modifier.clearAndSetSemantics {}.size(32.dp), colors = IconButtonDefaults.filledIconButtonColors(containerColor = containerColor), ) { Icon( imageVector = if (isGridView) Icons.AutoMirrored.Outlined.List else Icons.Outlined.GridView, contentDescription = null, modifier = Modifier.size(16.dp), ) } Spacer(Modifier.width(4.dp)) FilledIconButton( onClick = onShowMenu, modifier = Modifier.clearAndSetSemantics {}.size(32.dp), colors = IconButtonDefaults.filledIconButtonColors(containerColor = containerColor), ) { Icon( imageVector = Icons.Outlined.MoreVert, contentDescription = null, modifier = Modifier.size(16.dp), ) } } } internal class DownloadPageV2Test { private val mockDownloader = object : DownloaderV2 { private val map = mutableStateMapOf() init { val viewState = Task.ViewState(title = "Sample title", uploader = "dummy video uploader") val list = listOf( Task.State(Idle, null, viewState), Task.State(Canceled(Task.RestartableAction.Download), null, viewState), Task.State(Completed(null), null, viewState), ) map.run { repeat(9) { put(Task(url = "$it", preferences = PreferencesMock), list[it % 3]) } } val scope = CoroutineScope(SupervisorJob()) scope.launch(Dispatchers.Default) { while (true) { delay(1000) val newEntries = map.toMap().map { (task, state) -> val newDownloadState = when (state.downloadState) { is Canceled -> Idle is Completed -> Idle is Error -> Idle is FetchingInfo -> ReadyWithInfo Idle -> FetchingInfo(Job(), task.id) ReadyWithInfo -> Running(Job(), task.id) is Running -> { val preState: Running = state.downloadState if (preState.progress >= 1f) Completed(null) else preState.copy(progress = preState.progress + 0.1f) } } task to state.copy(downloadState = newDownloadState) } Snapshot.withMutableSnapshot { newEntries.forEach { (task, state) -> delay(100) map[task] = state } } } } } override fun getTaskStateMap(): SnapshotStateMap { return map } override fun cancel(task: Task): Boolean { return false } override fun restart(task: Task) {} override fun enqueue(task: Task) {} override fun enqueue(task: Task, state: Task.State) {} override fun remove(task: Task): Boolean { return true } } @Composable @Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(name = "Tablet", device = "spec:width=600dp,height=800dp,dpi=240") private fun Preview() { val downloader: DownloaderV2 = mockDownloader SealTheme { Column() { DownloadPageImplV2( taskDownloadStateMap = downloader.getTaskStateMap(), onActionPost = { task, state -> }, onMenuOpen = {}, ) } } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/downloadv2/TopBarNestedScrollConnection.kt ================================================ package com.junkfood.seal.ui.page.downloadv2 import androidx.compose.animation.core.AnimationState import androidx.compose.animation.core.DecayAnimationSpec import androidx.compose.animation.core.animateDecay import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.unit.Velocity import kotlin.math.abs private const val TAG = "TopBarNestedScrollConne" /* offset < 0 = scroll down, finger & content going upward offset > 0 = scroll up, finger & content going downward */ internal class TopBarNestedScrollConnection( private val maxOffset: Float, private val flingAnimationSpec: DecayAnimationSpec, private val offset: () -> Float, private val onOffsetUpdate: (Float) -> Unit, ) : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { val delta = available.y if (delta < 0f) { val previousOffset = offset() if (previousOffset >= 0) { val newOffset = (previousOffset + delta).coerceIn(0f, maxOffset) onOffsetUpdate(newOffset) val consumedOffset = newOffset - previousOffset return Offset(x = 0f, y = consumedOffset) } } return super.onPreScroll(available, source) } override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource, ): Offset { val delta = available.y val consumedY = consumed.y val previousOffset = offset() if (delta < 0f || consumedY < 0f) { if (previousOffset >= 0) { val newOffset = (previousOffset + consumedY).coerceIn(0f, maxOffset) onOffsetUpdate(newOffset) val consumedOffset = newOffset - previousOffset return Offset(0f, consumedOffset) } } if (delta > 0f) { val newOffset = (previousOffset + delta).coerceIn(0f, maxOffset) onOffsetUpdate(newOffset) val consumedOffset = newOffset - previousOffset return Offset(0f, consumedOffset) } return super.onPostScroll(consumed, available, source) } override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { val superConsumed = super.onPostFling(consumed, available) return superConsumed + settleAppBar(available.y) } private suspend fun settleAppBar(velocity: Float): Velocity { if (offset() < 0.01f) { return Velocity.Zero } var remainingVelocity = velocity if (abs(velocity) > 1f) { var lastValue = 0f AnimationState(initialValue = 0f, initialVelocity = velocity).animateDecay( flingAnimationSpec ) { val delta = value - lastValue val newOffset = (offset() + delta).coerceIn(0f, maxOffset) onOffsetUpdate(newOffset) val consumed = abs(newOffset - offset()) lastValue = value remainingVelocity = this.velocity // avoid rounding errors and stop if anything is unconsumed if (abs(delta - consumed) > 0.5f) this.cancelAnimation() } } return Velocity(0f, remainingVelocity) } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/downloadv2/VideoCardV2.kt ================================================ package com.junkfood.seal.ui.page.downloadv2 import android.content.res.Configuration import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.outlined.Cancel import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.Error import androidx.compose.material.icons.rounded.Pause import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.RestartAlt import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.junkfood.seal.R import com.junkfood.seal.download.Task import com.junkfood.seal.download.Task.DownloadState.Canceled import com.junkfood.seal.download.Task.DownloadState.Completed import com.junkfood.seal.download.Task.DownloadState.Error import com.junkfood.seal.download.Task.DownloadState.FetchingInfo import com.junkfood.seal.download.Task.DownloadState.Idle import com.junkfood.seal.download.Task.DownloadState.ReadyWithInfo import com.junkfood.seal.download.Task.DownloadState.Running import com.junkfood.seal.download.Task.RestartableAction import com.junkfood.seal.ui.common.AsyncImageImpl import com.junkfood.seal.ui.common.LocalDarkTheme import com.junkfood.seal.ui.common.LocalFixedColorRoles import com.junkfood.seal.ui.common.motion.materialSharedAxisY import com.junkfood.seal.ui.component.GreenTonalPalettes import com.junkfood.seal.ui.theme.SealTheme import com.junkfood.seal.util.toDurationText import com.junkfood.seal.util.toFileSizeText import kotlinx.coroutines.Job import kotlinx.coroutines.delay private val IconButtonSize = 64.dp private val IconSize = 36.dp private val ActionButtonContainerColor: Color @Composable get() = LocalFixedColorRoles.current.onSecondaryFixed.copy(alpha = 0.68f) private val ActionButtonContentColor: Color @Composable get() = LocalFixedColorRoles.current.secondaryFixed private val LabelContainerColor: Color = Color.Black.copy(alpha = 0.68f) @Composable fun VideoCardV2( modifier: Modifier = Modifier, viewState: Task.ViewState, stateIndicator: @Composable (BoxScope.() -> Unit)? = null, actionButton: @Composable (BoxScope.() -> Unit)? = null, onButtonClick: () -> Unit, ) { with(viewState) { VideoCardV2( modifier = modifier, thumbnailModel = thumbnailUrl, title = title, uploader = uploader, duration = duration, fileSizeApprox = fileSizeApprox, stateIndicator = stateIndicator, actionButton = actionButton, onButtonClick = onButtonClick, ) } } @Composable fun VideoListItem( modifier: Modifier = Modifier, viewState: Task.ViewState, stateIndicator: @Composable (() -> Unit)? = null, onButtonClick: () -> Unit, ) { with(viewState) { VideoListItem( modifier = modifier, thumbnailModel = thumbnailUrl, title = title, uploader = uploader, duration = duration, fileSizeApprox = fileSizeApprox, stateIndicator = stateIndicator, onButtonClick = onButtonClick, ) } } @Composable fun VideoListItem( modifier: Modifier = Modifier, thumbnailModel: Any? = null, title: String = "", uploader: String = "", duration: Int = 0, fileSizeApprox: Double = .0, stateIndicator: @Composable (() -> Unit)? = null, onButtonClick: () -> Unit, ) { Row(modifier = modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.Top) { Box(modifier = Modifier) { ListItemImage(modifier = Modifier, thumbnailModel = thumbnailModel) VideoInfoLabel( modifier = Modifier.align(Alignment.BottomEnd), duration = duration, fileSizeApprox = fileSizeApprox, ) } Box { Column(modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp)) { TitleText( modifier = Modifier, title = title, uploader = uploader, contentPadding = PaddingValues(), ) Spacer(modifier = Modifier.height(4.dp)) stateIndicator?.invoke() } IconButton( onButtonClick, modifier = Modifier.align(Alignment.BottomEnd).offset(x = 8.dp, y = 8.dp), ) { Icon( imageVector = Icons.Outlined.MoreVert, contentDescription = stringResource(R.string.show_more_actions), modifier = Modifier.size(20.dp), ) } } } } @Preview @Composable @Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES) private fun VideoListItemPreview() { SealTheme { val fakeStateList = listOf( Running(Job(), "", 0.58f), Error(throwable = Throwable(), RestartableAction.Download), FetchingInfo(Job(), ""), Canceled(RestartableAction.Download), ReadyWithInfo, Idle, Completed(null), ) var downloadState: Task.DownloadState by remember { mutableStateOf(Idle) } LaunchedEffect(Unit) { fakeStateList.forEach { delay(2000) downloadState = it } } Surface { VideoListItem( modifier = Modifier.padding(vertical = 8.dp, horizontal = 20.dp), thumbnailModel = R.drawable.sample3, title = stringResource(R.string.video_title_sample_text), uploader = stringResource(R.string.video_creator_sample_text), stateIndicator = { ListItemStateText( modifier = Modifier.padding(top = 3.dp), downloadState = downloadState, ) }, ) {} } } } @Composable fun VideoCardV2( modifier: Modifier = Modifier, thumbnailModel: Any? = null, title: String = "", uploader: String = "", duration: Int = 0, fileSizeApprox: Double = .0, stateIndicator: @Composable (BoxScope.() -> Unit)? = null, actionButton: @Composable (BoxScope.() -> Unit)? = null, onButtonClick: () -> Unit, ) { val containerColor = MaterialTheme.colorScheme.run { if (LocalDarkTheme.current.isDarkTheme()) surfaceContainer else surfaceContainerLowest } Card( modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = containerColor), ) { Column { Box(Modifier.fillMaxWidth()) { CardImage(modifier = Modifier, thumbnailModel = thumbnailModel) Box(Modifier.align(Alignment.TopStart)) { stateIndicator?.invoke(this) } Box(Modifier.align(Alignment.Center)) { actionButton?.invoke(this) } VideoInfoLabel( modifier = Modifier.align(Alignment.BottomEnd), duration = duration, fileSizeApprox = fileSizeApprox, ) } Row(modifier = Modifier.fillMaxWidth()) { TitleText(modifier = Modifier.weight(1f), title = title, uploader = uploader) IconButton(onButtonClick, modifier = Modifier.align(Alignment.CenterVertically)) { Icon( imageVector = Icons.Outlined.MoreVert, contentDescription = stringResource(R.string.show_more_actions), modifier = Modifier.size(20.dp), ) } } } } } @Composable @Preview @Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES) fun VideoCardV2Preview() { SealTheme { val downloadState = Error(throwable = Throwable(), action = RestartableAction.Download) VideoCardV2( thumbnailModel = R.drawable.sample3, title = stringResource(R.string.video_title_sample_text), uploader = stringResource(R.string.video_creator_sample_text), actionButton = { ActionButton(modifier = Modifier, downloadState = downloadState) {} }, stateIndicator = { CardStateIndicator(modifier = Modifier, downloadState = downloadState) }, ) {} } } @Composable private fun CardImage(modifier: Modifier = Modifier, thumbnailModel: Any? = null) { if (thumbnailModel != null) { AsyncImageImpl( modifier = modifier .padding() .fillMaxWidth() .aspectRatio(16f / 9f, matchHeightConstraintsFirst = true), model = thumbnailModel, contentDescription = null, contentScale = ContentScale.Crop, ) } else { Surface( modifier = modifier .padding() .fillMaxWidth() .aspectRatio(16f / 9f, matchHeightConstraintsFirst = true), color = MaterialTheme.colorScheme.surfaceContainerHighest, ) {} } } @Composable private fun ListItemImage(modifier: Modifier = Modifier, thumbnailModel: Any? = null) { if (thumbnailModel != null) { AsyncImageImpl( model = thumbnailModel, modifier = Modifier.width(160.dp) .aspectRatio(16f / 9f, matchHeightConstraintsFirst = true) .clip(MaterialTheme.shapes.extraSmall), contentScale = ContentScale.Crop, contentDescription = null, ) } else { Box( modifier = modifier .width(160.dp) .aspectRatio(16f / 9f, matchHeightConstraintsFirst = true) .clip(MaterialTheme.shapes.extraSmall) .background(MaterialTheme.colorScheme.surfaceContainerHighest) ) {} } } @Composable private fun TitleText( modifier: Modifier = Modifier, title: String, uploader: String, contentPadding: PaddingValues = PaddingValues(12.dp), ) { Column( modifier = modifier.padding(contentPadding), horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.Top, ) { Text( text = title, style = MaterialTheme.typography.titleSmall, maxLines = 2, overflow = TextOverflow.Ellipsis, ) Text( modifier = Modifier.padding(top = 3.dp), text = uploader, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } @Composable private fun VideoInfoLabel(modifier: Modifier = Modifier, duration: Int, fileSizeApprox: Double) { Surface( modifier = modifier.padding(4.dp), color = LabelContainerColor, shape = MaterialTheme.shapes.extraSmall, ) { val fileSizeText = fileSizeApprox.toFileSizeText() val durationText = duration.toDurationText() Text( modifier = Modifier.padding(horizontal = 4.dp), text = "$fileSizeText $durationText", style = MaterialTheme.typography.labelSmall, color = Color.White, ) } } @Composable fun CardStateIndicator(modifier: Modifier = Modifier, downloadState: Task.DownloadState) { Surface( modifier = modifier.padding(vertical = 12.dp, horizontal = 8.dp), color = LabelContainerColor, shape = MaterialTheme.shapes.extraSmall, ) { CardItemStateText( modifier = Modifier.padding(horizontal = 4.dp), downloadState = downloadState, ) } } @Composable fun ListItemStateText( modifier: Modifier = Modifier, isDarkTheme: Boolean = LocalDarkTheme.current.isDarkTheme(), downloadState: Task.DownloadState, ) { val sizeModifier = Modifier.size(14.dp) AnimatedContent( downloadState, transitionSpec = { materialSharedAxisY(initialOffsetY = { it / 5 }, targetOffsetY = { -it / 5 }) }, contentKey = { it::class.simpleName }, ) { downloadState -> val text = when (downloadState) { is Canceled -> stringResource(R.string.status_canceled) is Completed -> stringResource(R.string.status_downloaded) is Error -> stringResource(R.string.status_error) is FetchingInfo -> stringResource(R.string.status_fetching_video_info) Idle -> stringResource(R.string.status_enqueued) ReadyWithInfo -> stringResource(R.string.status_enqueued) is Running -> { val progress = downloadState.progress if (progress >= 0) { "%.1f %%".format(downloadState.progress * 100) } else { stringResource(R.string.status_downloading) } } } Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { when (downloadState) { is Canceled -> { Icon( imageVector = Icons.Outlined.Cancel, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = sizeModifier, ) } is Completed -> { val color = GreenTonalPalettes.accent1(if (isDarkTheme) 80.0 else 40.0) Icon( imageVector = Icons.Filled.CheckCircle, contentDescription = null, tint = color, modifier = sizeModifier, ) } is Error -> { Icon( imageVector = Icons.Rounded.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = sizeModifier, ) } is FetchingInfo, Idle, ReadyWithInfo -> { CircularProgressIndicator(modifier = sizeModifier, strokeWidth = 2.5.dp) } is Running -> { val progress = downloadState.progress CircularProgressIndicator( progress = { progress }, modifier = sizeModifier, strokeWidth = 2.5.dp, ) } } Spacer(Modifier.width(8.dp)) Text( text = text, modifier = Modifier, style = MaterialTheme.typography.labelMedium.merge(letterSpacing = 0.sp), color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } @Composable private fun CardItemStateText(modifier: Modifier = Modifier, downloadState: Task.DownloadState) { val errorColor = MaterialTheme.colorScheme.run { if (LocalDarkTheme.current.isDarkTheme()) error else errorContainer } val textStyle = MaterialTheme.typography.labelSmall val contentColor = Color.White val text = when (downloadState) { is Canceled -> R.string.status_canceled is Completed -> R.string.status_downloaded is Error -> R.string.status_error is FetchingInfo -> R.string.status_fetching_video_info Idle -> R.string.status_enqueued ReadyWithInfo -> R.string.status_enqueued is Running -> R.string.status_downloading } Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { if (downloadState is Error) { Icon( imageVector = Icons.Rounded.Error, contentDescription = null, tint = errorColor, modifier = Modifier.size(12.dp), ) Spacer(Modifier.width(4.dp)) } Text( text = stringResource(id = text), modifier = Modifier, style = textStyle, color = contentColor, ) } } @Composable fun ActionButton( modifier: Modifier = Modifier, downloadState: Task.DownloadState, onActionPost: (UiAction) -> Unit, ) = when (downloadState) { is Error -> { RestartButton(modifier = modifier) { onActionPost(UiAction.Resume) } } is Canceled -> { ResumeButton(modifier = modifier, downloadState.progress) { onActionPost(UiAction.Resume) } } is Completed -> { PlayVideoButton(modifier = modifier) { onActionPost(UiAction.OpenFile(downloadState.filePath)) } } is FetchingInfo, ReadyWithInfo, Idle -> { ProgressButton(modifier = modifier, progress = -1f) { onActionPost(UiAction.Cancel) } } is Running -> { ProgressButton(modifier = modifier, progress = downloadState.progress) { onActionPost(UiAction.Cancel) } } } @Composable private fun ResumeButton( modifier: Modifier = Modifier, progress: Float? = null, onClick: () -> Unit, ) { val background = ActionButtonContainerColor Box( modifier = modifier .size(IconButtonSize) .clip(CircleShape) .drawBehind { drawCircle(background) } .clickable(onClickLabel = stringResource(R.string.cancel), onClick = onClick) ) { if (progress != null) { CircularProgressIndicator( progress = { progress }, modifier = Modifier.size(IconButtonSize).align(Alignment.Center), color = ActionButtonContentColor, trackColor = Color.Transparent, gapSize = 0.dp, ) } Icon( imageVector = Icons.Rounded.Download, contentDescription = stringResource(R.string.restart), modifier = Modifier.size(IconSize).align(Alignment.Center), tint = ActionButtonContentColor, ) } } @Composable fun RestartButton(modifier: Modifier = Modifier, onClick: () -> Unit) { val background = ActionButtonContainerColor Box( modifier = modifier .size(IconButtonSize) .clip(CircleShape) .drawBehind { drawCircle(background) } .clickable(onClickLabel = stringResource(R.string.cancel), onClick = onClick) ) { Icon( imageVector = Icons.Rounded.RestartAlt, contentDescription = stringResource(R.string.restart), modifier = Modifier.size(IconSize).align(Alignment.Center), tint = ActionButtonContentColor, ) } } @Composable private fun PlayVideoButton(modifier: Modifier = Modifier, onClick: () -> Unit) { FilledIconButton( onClick = onClick, modifier = modifier.size(IconButtonSize), colors = IconButtonDefaults.filledIconButtonColors( containerColor = ActionButtonContainerColor, contentColor = ActionButtonContentColor, ), ) { Icon( imageVector = Icons.Rounded.PlayArrow, contentDescription = stringResource(R.string.open_file), modifier = Modifier.size(IconSize), ) } } @Composable private fun ProgressButton(modifier: Modifier = Modifier, progress: Float, onClick: () -> Unit) { val animatedProgress by animateFloatAsState( progress, animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, label = "progress", ) val background = ActionButtonContainerColor Box( modifier = modifier .size(IconButtonSize) .clip(CircleShape) .drawBehind { drawCircle(background) } .clickable(onClickLabel = stringResource(R.string.cancel), onClick = onClick) ) { if (progress < 0) { CircularProgressIndicator( modifier = Modifier.size(IconButtonSize).align(Alignment.Center), color = ActionButtonContentColor, trackColor = Color.Transparent, ) } else { CircularProgressIndicator( { animatedProgress }, modifier = Modifier.size(IconButtonSize).align(Alignment.Center), color = ActionButtonContentColor, gapSize = 0.dp, trackColor = Color.Transparent, ) } Icon( imageVector = Icons.Rounded.Pause, contentDescription = null, modifier = Modifier.align(Alignment.Center).size(IconSize), tint = ActionButtonContentColor, ) } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/downloadv2/configure/DownloadDialogV2.kt ================================================ package com.junkfood.seal.ui.page.downloadv2.configure import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Spring import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.DownloadDone import androidx.compose.material.icons.filled.SettingsSuggest import androidx.compose.material.icons.filled.VideoFile import androidx.compose.material.icons.outlined.Cancel import androidx.compose.material.icons.outlined.Code import androidx.compose.material.icons.outlined.DoneAll import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material.icons.outlined.FileDownload import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.NewLabel import androidx.compose.material.icons.outlined.SettingsSuggest import androidx.compose.material.icons.outlined.VideoFile import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.junkfood.seal.App import com.junkfood.seal.R import com.junkfood.seal.ui.common.HapticFeedback.longPressHapticFeedback import com.junkfood.seal.ui.common.motion.materialSharedAxisX import com.junkfood.seal.ui.component.ButtonChip import com.junkfood.seal.ui.component.DrawerSheetSubtitle import com.junkfood.seal.ui.component.OutlinedButtonWithIcon import com.junkfood.seal.ui.component.SealModalBottomSheet import com.junkfood.seal.ui.component.SealModalBottomSheetM2Variant import com.junkfood.seal.ui.component.SingleChoiceChip import com.junkfood.seal.ui.component.SingleChoiceSegmentedButton import com.junkfood.seal.ui.component.VideoFilterChip import com.junkfood.seal.ui.page.command.TemplatePickerDialog import com.junkfood.seal.ui.page.downloadv2.configure.ActionButton.Download import com.junkfood.seal.ui.page.downloadv2.configure.ActionButton.FetchInfo import com.junkfood.seal.ui.page.downloadv2.configure.ActionButton.StartTask import com.junkfood.seal.ui.page.downloadv2.configure.DownloadDialogViewModel.Action import com.junkfood.seal.ui.page.downloadv2.configure.DownloadDialogViewModel.SelectionState import com.junkfood.seal.ui.page.downloadv2.configure.DownloadDialogViewModel.SheetState.Configure import com.junkfood.seal.ui.page.downloadv2.configure.DownloadDialogViewModel.SheetState.Error import com.junkfood.seal.ui.page.downloadv2.configure.DownloadDialogViewModel.SheetState.InputUrl import com.junkfood.seal.ui.page.downloadv2.configure.DownloadDialogViewModel.SheetState.Loading import com.junkfood.seal.ui.page.settings.command.CommandTemplateDialog import com.junkfood.seal.ui.page.settings.format.AudioQuickSettingsDialog import com.junkfood.seal.ui.page.settings.format.VideoQuickSettingsDialog import com.junkfood.seal.ui.page.settings.network.CookiesQuickSettingsDialog import com.junkfood.seal.ui.theme.SealTheme import com.junkfood.seal.util.AUDIO_CONVERSION_FORMAT import com.junkfood.seal.util.AUDIO_CONVERT import com.junkfood.seal.util.AUDIO_FORMAT import com.junkfood.seal.util.AUDIO_QUALITY import com.junkfood.seal.util.COOKIES import com.junkfood.seal.util.CUSTOM_COMMAND import com.junkfood.seal.util.DatabaseUtil import com.junkfood.seal.util.DownloadType import com.junkfood.seal.util.DownloadType.Audio import com.junkfood.seal.util.DownloadType.Command import com.junkfood.seal.util.DownloadType.Playlist import com.junkfood.seal.util.DownloadType.Video import com.junkfood.seal.util.DownloadType.entries import com.junkfood.seal.util.DownloadUtil import com.junkfood.seal.util.FORMAT_SELECTION import com.junkfood.seal.util.PreferenceStrings import com.junkfood.seal.util.PreferenceUtil import com.junkfood.seal.util.PreferenceUtil.getBoolean import com.junkfood.seal.util.PreferenceUtil.updateBoolean import com.junkfood.seal.util.PreferenceUtil.updateInt import com.junkfood.seal.util.SUBTITLE import com.junkfood.seal.util.TEMPLATE_ID import com.junkfood.seal.util.THUMBNAIL import com.junkfood.seal.util.ToastUtil import com.junkfood.seal.util.USE_CUSTOM_AUDIO_PRESET import com.junkfood.seal.util.VIDEO_FORMAT import com.junkfood.seal.util.VIDEO_QUALITY import kotlinx.coroutines.launch @Composable private fun DownloadType.label(): String = stringResource( when (this) { Audio -> R.string.audio Video -> R.string.video Command -> R.string.commands Playlist -> R.string.playlist } ) val PreferencesMock = DownloadUtil.DownloadPreferences.EMPTY data class Config( val downloadType: DownloadType? = PreferenceUtil.getDownloadType(), val typeEntries: List = when (CUSTOM_COMMAND.getBoolean()) { true -> DownloadType.entries false -> DownloadType.entries - Command }, val useFormatSelection: Boolean = FORMAT_SELECTION.getBoolean(), val savedLinks: Set = PreferenceUtil.getSavedLinks(), ) { companion object { fun updatePreferences(newValue: Config, oldValue: Config) { with(newValue) { if (downloadType != oldValue.downloadType) { downloadType?.let { PreferenceUtil.updateDownloadType(it) } } if (useFormatSelection != oldValue.useFormatSelection) { FORMAT_SELECTION.updateBoolean(useFormatSelection) } if (savedLinks != oldValue.savedLinks) { PreferenceUtil.updateSavedLinks(savedLinks) } } } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun DownloadDialog( modifier: Modifier = Modifier, config: Config, sheetState: SheetState, preferences: DownloadUtil.DownloadPreferences, onPreferencesUpdate: (DownloadUtil.DownloadPreferences) -> Unit, state: DownloadDialogViewModel.SheetState = InputUrl, onActionPost: (Action) -> Unit = {}, ) { var showVideoPresetDialog by remember { mutableStateOf(false) } var showAudioPresetDialog by remember { mutableStateOf(false) } SealModalBottomSheet( sheetState = sheetState, contentPadding = PaddingValues(), onDismissRequest = { onActionPost(Action.HideSheet) }, ) { DownloadDialogContent( modifier = modifier, state = state, config = config, preferences = preferences, onPreferencesUpdate = onPreferencesUpdate, onPresetEdit = { type -> when (type) { Audio -> showAudioPresetDialog = true Video -> showVideoPresetDialog = true else -> {} } }, onActionPost = onActionPost, ) } if (showVideoPresetDialog) { var res by remember(preferences) { mutableIntStateOf(preferences.videoResolution) } var format by remember(preferences) { mutableIntStateOf(preferences.videoFormat) } VideoQuickSettingsDialog( videoResolution = res, videoFormatPreference = format, onResolutionSelect = { res = it }, onFormatSelect = { format = it }, onDismissRequest = { showVideoPresetDialog = false }, onSave = { VIDEO_FORMAT.updateInt(format) VIDEO_QUALITY.updateInt(res) onPreferencesUpdate(DownloadUtil.DownloadPreferences.createFromPreferences()) }, ) } if (showAudioPresetDialog) { var quality by remember(preferences) { mutableIntStateOf(preferences.audioQuality) } var customPreset by remember(preferences) { mutableStateOf(preferences.useCustomAudioPreset) } var conversionFmt by remember(preferences) { mutableIntStateOf(preferences.audioConvertFormat) } var convertAudio by remember(preferences) { mutableStateOf(preferences.convertAudio) } var preferredFormat by remember(preferences) { mutableIntStateOf(preferences.audioFormat) } AudioQuickSettingsDialog( modifier = Modifier, preferences = preferences, audioQuality = quality, onQualitySelect = { quality = it }, useCustomAudioPreset = customPreset, onCustomPresetToggle = { customPreset = it }, convertAudio = convertAudio, onConvertToggled = { convertAudio = it }, conversionFormat = conversionFmt, onConversionSelect = { conversionFmt = it }, preferredFormat = preferredFormat, onPreferredSelect = { preferredFormat = it }, onDismissRequest = { showAudioPresetDialog = false }, onSave = { AUDIO_QUALITY.updateInt(quality) USE_CUSTOM_AUDIO_PRESET.updateBoolean(customPreset) AUDIO_CONVERSION_FORMAT.updateInt(conversionFmt) AUDIO_CONVERT.updateBoolean(convertAudio) AUDIO_FORMAT.updateInt(preferredFormat) onPreferencesUpdate(DownloadUtil.DownloadPreferences.createFromPreferences()) }, ) } } @Composable private fun ErrorPage(modifier: Modifier = Modifier, state: Error, onActionPost: (Action) -> Unit) { val view = LocalView.current val clipboardManager = LocalClipboardManager.current val url = state.action.run { when (this) { is Action.FetchFormats -> url is Action.FetchPlaylist -> url else -> { throw IllegalArgumentException() } } } Column(modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { Icon( imageVector = Icons.Outlined.ErrorOutline, contentDescription = null, modifier = Modifier.size(40.dp), ) Text( text = stringResource(R.string.fetch_info_error_msg), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 12.dp), ) Text( text = state.throwable.message.toString(), style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 16.dp, horizontal = 20.dp) .fillMaxWidth() .verticalScroll(rememberScrollState()), maxLines = 20, overflow = TextOverflow.Clip, ) Row(modifier = Modifier) { FilledTonalButton(onClick = { onActionPost(state.action) }) { Text("Retry") } Spacer(Modifier.width(8.dp)) Button( onClick = { view.longPressHapticFeedback() clipboardManager.setText( AnnotatedString( App.getVersionReport() + "\nURL: ${url}\n${state.throwable.message}" ) ) ToastUtil.makeToast(R.string.error_copied) } ) { Text(stringResource(R.string.copy_error_report)) } } } } @Composable private fun DownloadDialogContent( modifier: Modifier = Modifier, state: DownloadDialogViewModel.SheetState, config: Config, preferences: DownloadUtil.DownloadPreferences, onPreferencesUpdate: (DownloadUtil.DownloadPreferences) -> Unit, onPresetEdit: (DownloadType?) -> Unit, onActionPost: (Action) -> Unit, ) { AnimatedContent( modifier = modifier, targetState = state, label = "", transitionSpec = { materialSharedAxisX(initialOffsetX = { it / 4 }, targetOffsetX = { -it / 4 }) }, ) { state -> when (state) { is Configure -> { check(state.urlList.isNotEmpty()) if (state.urlList.size == 1) { ConfigurePage( url = state.urlList.first(), config = config, preferences = preferences, onPresetEdit = onPresetEdit, onConfigSave = { Config.updatePreferences(newValue = it, oldValue = config) }, settingChips = { AdditionalSettings( modifier = Modifier.padding(horizontal = 16.dp), isQuickDownload = false, preference = preferences, selectedType = config.downloadType, onPreferenceUpdate = { onPreferencesUpdate( DownloadUtil.DownloadPreferences.createFromPreferences() ) }, ) }, onActionPost = { onActionPost(it) }, ) } else { ConfigurePagePlaylistVariant( initialDownloadType = config.downloadType ?: Video, preferences = preferences, onPreferencesUpdate = onPreferencesUpdate, onPresetEdit = onPresetEdit, onDismissRequest = { onActionPost(Action.HideSheet) }, ) { onActionPost( Action.DownloadWithPreset( urlList = state.urlList, preferences = preferences.copy(extractAudio = it == Audio), ) ) } } } is Error -> { ErrorPage(state = state, onActionPost = onActionPost) } is Loading -> { Column(modifier = Modifier.fillMaxWidth().padding(vertical = 120.dp)) { CircularProgressIndicator( modifier = Modifier.align(Alignment.CenterHorizontally) ) } } InputUrl -> { InputUrlPage( config = config, onConfigUpdate = { Config.updatePreferences(newValue = it, oldValue = config) }, onActionPost = onActionPost, ) } } } } @OptIn(ExperimentalMaterial3Api::class) @Preview @Composable private fun ErrorPreview() { SealModalBottomSheet( onDismissRequest = {}, sheetState = with(LocalDensity.current) { SheetState( initialValue = SheetValue.Expanded, skipPartiallyExpanded = true, velocityThreshold = { 56.dp.toPx() }, positionalThreshold = { 125.dp.toPx() }, ) }, ) { ErrorPage( state = Error( action = Action.FetchFormats( url = "", audioOnly = true, preferences = PreferencesMock, ), throwable = Exception("Not good"), ), onActionPost = {}, ) } } @Composable fun FormatPage( modifier: Modifier = Modifier, state: SelectionState.FormatSelection, onDismissRequest: () -> Unit, ) { val sheetState = androidx.compose.material.rememberModalBottomSheetState( initialValue = ModalBottomSheetValue.Hidden, skipHalfExpanded = true, ) LaunchedEffect(state) { sheetState.show() } val scope = rememberCoroutineScope() BackHandler { scope.launch { sheetState.hide() }.invokeOnCompletion { onDismissRequest() } } SealModalBottomSheetM2Variant(sheetState = sheetState, sheetGesturesEnabled = false) { FormatPage( modifier = modifier, videoInfo = state.info, onNavigateBack = { scope.launch { sheetState.hide() }.invokeOnCompletion { onDismissRequest() } }, ) } } @OptIn(ExperimentalMaterial3Api::class) /*@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) @Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO)*/ @Composable private fun ConfigurePagePreview() { SealTheme() { SealModalBottomSheet( sheetState = with(LocalDensity.current) { SheetState( initialValue = SheetValue.Expanded, skipPartiallyExpanded = true, velocityThreshold = { 56.dp.toPx() }, positionalThreshold = { 125.dp.toPx() }, ) }, onDismissRequest = {}, contentPadding = PaddingValues(), ) { ConfigurePage( config = Config( downloadType = Audio, useFormatSelection = true, typeEntries = entries - Command, ), preferences = PreferencesMock, onConfigSave = {}, settingChips = {}, ) {} } } } @Composable private fun ConfigurePage( modifier: Modifier = Modifier, url: String = "", config: Config, preferences: DownloadUtil.DownloadPreferences, settingChips: @Composable () -> Unit, onPresetEdit: (DownloadType?) -> Unit = {}, onConfigSave: (Config) -> Unit, onActionPost: (Action) -> Unit, ) { val scope = rememberCoroutineScope() var selectedType by remember(config) { mutableStateOf(config.downloadType) } var useFormatSelection by remember(config) { mutableStateOf(config.useFormatSelection) } val canProceed = selectedType in config.typeEntries var showTemplateSelectionDialog by remember { mutableStateOf(false) } var showTemplateCreatorDialog by remember { mutableStateOf(false) } var showTemplateEditorDialog by remember { mutableStateOf(false) } val template by remember(showTemplateCreatorDialog, showTemplateSelectionDialog, showTemplateEditorDialog) { mutableStateOf(PreferenceUtil.getTemplate()) } LaunchedEffect(selectedType) { if (selectedType == Playlist) { useFormatSelection = false } } Column { Column(modifier = modifier.padding(horizontal = 20.dp)) { Header( modifier = Modifier.align(Alignment.CenterHorizontally), title = stringResource(R.string.settings_before_download), icon = Icons.Outlined.DoneAll, ) DrawerSheetSubtitle(text = stringResource(id = R.string.download_type)) DownloadTypeSelectionGroup( typeEntries = config.typeEntries, selectedType = selectedType, onSelect = { selectedType = it }, ) Column(modifier = Modifier.animateContentSize()) { if (selectedType != Command) { DrawerSheetSubtitle( text = stringResource(id = R.string.format_selection), modifier = Modifier, ) Preset( modifier = Modifier, preference = preferences, selected = !useFormatSelection, downloadType = selectedType, onClick = { useFormatSelection = false }, showEditIcon = !useFormatSelection && selectedType != Playlist, onEdit = { onPresetEdit(selectedType) }, ) Custom( selected = useFormatSelection, enabled = selectedType != Playlist, onClick = { useFormatSelection = true }, ) } else { if (showTemplateSelectionDialog) { TemplatePickerDialog { showTemplateSelectionDialog = false } } if (showTemplateCreatorDialog) { CommandTemplateDialog( onDismissRequest = { showTemplateCreatorDialog = false }, confirmationCallback = { scope.launch { TEMPLATE_ID.updateInt(it) } }, ) } if (showTemplateEditorDialog) { CommandTemplateDialog( commandTemplate = template, onDismissRequest = { showTemplateEditorDialog = false }, ) } DrawerSheetSubtitle( text = stringResource(id = R.string.template_selection), modifier = Modifier, ) LazyRow(modifier = Modifier) { item { ButtonChip( icon = Icons.Outlined.Code, label = template.name, onClick = { showTemplateSelectionDialog = true }, ) } item { ButtonChip( icon = Icons.Outlined.NewLabel, label = stringResource(id = R.string.new_template), onClick = { showTemplateCreatorDialog = true }, ) } item { ButtonChip( icon = Icons.Outlined.Edit, label = stringResource(id = R.string.edit_template, template.name), onClick = { showTemplateEditorDialog = true }, ) } } } } } var expanded by remember { mutableStateOf(false) } ExpandableTitle(expanded = expanded, onClick = { expanded = true }) { settingChips() } ActionButtons( modifier = Modifier.padding(horizontal = 20.dp), canProceed = canProceed, selectedType = selectedType, useFormatSelection = useFormatSelection, onCancel = { onActionPost(Action.HideSheet) }, onDownload = { onConfigSave( config.copy( useFormatSelection = useFormatSelection, downloadType = selectedType, ) ) onActionPost( Action.DownloadWithPreset( urlList = listOf(url), preferences = preferences.copy(extractAudio = selectedType == Audio), ) ) }, onFetchInfo = { onConfigSave( config.copy( useFormatSelection = useFormatSelection, downloadType = selectedType, ) ) if (selectedType == Playlist) { onActionPost(Action.FetchPlaylist(url = url, preferences = preferences)) } else { onActionPost( Action.FetchFormats( url = url, audioOnly = selectedType == Audio, preferences = preferences, ) ) } }, onTaskStart = { onConfigSave( config.copy( useFormatSelection = useFormatSelection, downloadType = selectedType, ) ) onActionPost( Action.RunCommand(url = url, template = template, preferences = preferences) ) }, ) } } @Composable fun ConfigurePagePlaylistVariant( modifier: Modifier = Modifier, initialDownloadType: DownloadType, preferences: DownloadUtil.DownloadPreferences, onPreferencesUpdate: (DownloadUtil.DownloadPreferences) -> Unit, onPresetEdit: (DownloadType?) -> Unit = {}, onDismissRequest: () -> Unit, onDownload: (DownloadType) -> Unit, ) { var selectedType by remember(initialDownloadType) { mutableStateOf(initialDownloadType) } Column { Column(modifier = modifier.padding(horizontal = 20.dp)) { Header( modifier = Modifier.align(Alignment.CenterHorizontally), title = stringResource(R.string.settings_before_download), icon = Icons.Outlined.DoneAll, ) DrawerSheetSubtitle(text = stringResource(id = R.string.download_type)) DownloadTypeSelectionGroup( typeEntries = listOf(Video, Audio), selectedType = selectedType, onSelect = { selectedType = it }, ) DrawerSheetSubtitle( text = stringResource(id = R.string.format_selection), modifier = Modifier, ) Preset( modifier = Modifier, preference = preferences, selected = true, downloadType = selectedType, onClick = { onPresetEdit(selectedType) }, showEditIcon = true, onEdit = { onPresetEdit(selectedType) }, ) } var expanded by remember { mutableStateOf(false) } ExpandableTitle(expanded = expanded, onClick = { expanded = true }) { AdditionalSettings( modifier = Modifier.padding(horizontal = 16.dp), isQuickDownload = false, preference = preferences, selectedType = Audio, onPreferenceUpdate = { onPreferencesUpdate(DownloadUtil.DownloadPreferences.createFromPreferences()) }, ) } ActionButtons( modifier = Modifier.padding(horizontal = 20.dp), canProceed = true, selectedType = selectedType, useFormatSelection = false, onCancel = onDismissRequest, onDownload = { onDownload(initialDownloadType) onDismissRequest() }, onFetchInfo = { throw IllegalStateException() }, onTaskStart = { throw IllegalStateException() }, ) } } @Composable private fun AdditionalSettings( modifier: Modifier = Modifier, isQuickDownload: Boolean, selectedType: DownloadType?, preference: DownloadUtil.DownloadPreferences, onNavigateToCookieGeneratorPage: (String) -> Unit = {}, onPreferenceUpdate: () -> Unit, ) { val cookiesProfiles by DatabaseUtil.getCookiesFlow().collectAsStateWithLifecycle(emptyList()) var showCookiesDialog by rememberSaveable { mutableStateOf(false) } with(preference) { Row(modifier = modifier.fillMaxWidth().horizontalScroll(rememberScrollState())) { if (cookiesProfiles.isNotEmpty()) { VideoFilterChip( selected = preference.cookies, onClick = { if (isQuickDownload) { COOKIES.updateBoolean(!cookies) onPreferenceUpdate() } else { showCookiesDialog = true } }, label = stringResource(id = R.string.cookies), ) } VideoFilterChip( selected = downloadSubtitle, enabled = selectedType != Command, onClick = { SUBTITLE.updateBoolean(!downloadSubtitle) onPreferenceUpdate() }, label = stringResource(id = R.string.download_subtitles), ) VideoFilterChip( selected = createThumbnail, enabled = selectedType != Command, onClick = { THUMBNAIL.updateBoolean(!createThumbnail) onPreferenceUpdate() }, label = stringResource(R.string.create_thumbnail), ) } if (showCookiesDialog && cookiesProfiles.isNotEmpty()) { CookiesQuickSettingsDialog( onDismissRequest = { showCookiesDialog = false }, onConfirm = {}, cookieProfiles = cookiesProfiles, onCookieProfileClicked = { onNavigateToCookieGeneratorPage(it.url) }, isCookiesEnabled = cookies, onCookiesToggled = { COOKIES.updateBoolean(!cookies) onPreferenceUpdate() }, ) } } } @Composable fun ExpandableTitle( modifier: Modifier = Modifier, expanded: Boolean = false, onClick: () -> Unit = {}, content: @Composable () -> Unit, ) { Column { Spacer(Modifier.height(8.dp)) HorizontalDivider(thickness = Dp.Hairline, modifier = Modifier.padding(horizontal = 20.dp)) Column( modifier = modifier .clickable( onClick = onClick, onClickLabel = stringResource(R.string.show_more_actions), enabled = !expanded, ) .padding(top = 12.dp, bottom = 8.dp) ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { Spacer(modifier = Modifier.width(24.dp)) Text( text = stringResource(R.string.additional_settings), style = MaterialTheme.typography.labelLarge, ) Spacer(modifier = Modifier.weight(1f)) if (!expanded) { Icon( imageVector = Icons.Outlined.ExpandMore, contentDescription = null, modifier = Modifier.size(20.dp), ) Spacer(modifier = Modifier.width(32.dp)) } } AnimatedVisibility(expanded) { Column { Spacer(Modifier.height(8.dp)) content() } } } } } @Composable private fun SingleChoiceItem( modifier: Modifier = Modifier, title: String, desc: String, selected: Boolean, icon: (@Composable () -> Unit)? = null, action: (@Composable () -> Unit)? = null, enabled: Boolean = true, onClick: () -> Unit = {}, ) { val corner by animateDpAsState( if (selected) 28.dp else 16.dp, animationSpec = spring( stiffness = Spring.StiffnessMedium, visibilityThreshold = Dp.VisibilityThreshold, ), label = "", ) val color by animateColorAsState( if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainerLow, label = "", ) Surface( selected = selected, onClick = onClick, color = color, shape = RoundedCornerShape(corner), modifier = modifier.padding(vertical = 4.dp).run { if (!enabled) alpha(0.32f) else this }, enabled = enabled, ) { Row( modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f).heightIn(min = 48.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { icon?.invoke() Spacer(modifier = Modifier.width(12.dp)) Text(text = title, style = MaterialTheme.typography.titleMedium) } Text( text = desc, style = MaterialTheme.typography.bodySmall, minLines = 2, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(start = 32.dp), ) } action?.invoke() } } } @Composable internal fun Header(modifier: Modifier = Modifier, icon: ImageVector, title: String) { Column(modifier = modifier) { Icon( modifier = Modifier.align(Alignment.CenterHorizontally), imageVector = icon, contentDescription = null, ) Text( text = title, style = MaterialTheme.typography.headlineSmall, modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 16.dp, bottom = 8.dp), maxLines = 2, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, ) } } @Composable private fun DownloadTypeSelectionGroup( modifier: Modifier = Modifier, typeEntries: List, selectedType: DownloadType?, onSelect: (DownloadType) -> Unit, ) { val typeCount = typeEntries.size if (typeCount == DownloadType.entries.size) { LazyRow(modifier = modifier) { items(typeEntries) { type -> SingleChoiceChip( selected = selectedType == type, label = type.label(), onClick = { onSelect(type) }, ) } } } else { SingleChoiceSegmentedButtonRow(modifier = modifier.fillMaxWidth()) { typeEntries.forEachIndexed { index, type -> SingleChoiceSegmentedButton( selected = selectedType == type, onClick = { onSelect(type) }, shape = SegmentedButtonDefaults.itemShape(index, typeCount), ) { Text(text = type.label()) } } } } } @Composable private fun Preset( modifier: Modifier = Modifier, preference: DownloadUtil.DownloadPreferences, downloadType: DownloadType?, selected: Boolean, showEditIcon: Boolean, onEdit: () -> Unit, onClick: () -> Unit, ) { val description = when (downloadType) { Audio -> { PreferenceStrings.getAudioPresetText(preference) } Video -> { PreferenceStrings.getVideoPresetText(preference) } Playlist -> stringResource(R.string.preset_format_selection_desc) else -> "" } SingleChoiceItem( modifier = modifier, title = stringResource(R.string.preset), desc = description, icon = { Crossfade(selected, animationSpec = spring(stiffness = Spring.StiffnessMedium)) { if (it) { Icon( imageVector = Icons.Filled.SettingsSuggest, null, modifier = Modifier.size(20.dp), ) } else { Icon( imageVector = Icons.Outlined.SettingsSuggest, null, modifier = Modifier.size(20.dp), ) } } }, selected = selected, action = { Crossfade(showEditIcon, animationSpec = spring(stiffness = Spring.StiffnessMedium)) { if (it) { Icon( imageVector = Icons.Outlined.MoreVert, contentDescription = stringResource(R.string.edit), modifier = Modifier.size(20.dp), ) } } }, onClick = { if (showEditIcon) { onEdit() } else { onClick() } }, ) } @Composable private fun Custom( modifier: Modifier = Modifier, selected: Boolean, enabled: Boolean = true, onClick: () -> Unit, ) { SingleChoiceItem( modifier = modifier, title = stringResource(R.string.custom), desc = stringResource(R.string.custom_format_selection_desc), icon = { Crossfade(selected, animationSpec = spring(stiffness = Spring.StiffnessMedium)) { if (it) { Icon( imageVector = Icons.Filled.VideoFile, null, modifier = Modifier.size(20.dp), ) } else { Icon( imageVector = Icons.Outlined.VideoFile, null, modifier = Modifier.size(20.dp), ) } } }, selected = selected, enabled = enabled, onClick = onClick, ) } private enum class ActionButton { FetchInfo, Download, StartTask, } @Composable private fun ActionButton.Icon() { Icon( imageVector = when (this) { FetchInfo -> Icons.AutoMirrored.Filled.ArrowForward Download -> Icons.Outlined.FileDownload StartTask -> Icons.Filled.DownloadDone }, contentDescription = null, modifier = Modifier.size(18.dp), ) } @Composable private fun ActionButton.Label() { Text( stringResource( when (this) { FetchInfo -> R.string.proceed Download -> R.string.download StartTask -> R.string.start } ), modifier = Modifier.padding(start = 8.dp), ) } @Composable private fun ActionButtons( modifier: Modifier = Modifier, canProceed: Boolean, selectedType: DownloadType?, useFormatSelection: Boolean, onCancel: () -> Unit, onFetchInfo: () -> Unit, onDownload: () -> Unit, onTaskStart: () -> Unit, ) { val action = if (selectedType == Command) { StartTask } else if (selectedType == Playlist || useFormatSelection) { FetchInfo } else { Download } val state = rememberLazyListState() LazyRow( modifier = modifier.fillMaxWidth().padding(top = 12.dp), horizontalArrangement = Arrangement.End, state = state, verticalAlignment = Alignment.CenterVertically, ) { item { OutlinedButtonWithIcon( modifier = Modifier.padding(horizontal = 12.dp), onClick = onCancel, icon = Icons.Outlined.Cancel, text = stringResource(R.string.cancel), ) } item { Button( modifier = Modifier, onClick = { when (action) { FetchInfo -> onFetchInfo() Download -> onDownload() StartTask -> onTaskStart() } }, contentPadding = ButtonDefaults.ButtonWithIconContentPadding, enabled = canProceed, ) { AnimatedContent( targetState = action, label = "", transitionSpec = { (fadeIn(animationSpec = tween(220, delayMillis = 90))).togetherWith( fadeOut(animationSpec = tween(90)) ) }, ) { action -> Row(verticalAlignment = Alignment.CenterVertically) { action.Icon() action.Label() } } } } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/downloadv2/configure/DownloadDialogViewModel.kt ================================================ package com.junkfood.seal.ui.page.downloadv2.configure import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.junkfood.seal.database.objects.CommandTemplate import com.junkfood.seal.download.DownloaderV2 import com.junkfood.seal.download.Task import com.junkfood.seal.util.DownloadUtil import com.junkfood.seal.util.PlaylistResult import com.junkfood.seal.util.VideoInfo import com.yausername.youtubedl_android.YoutubeDL import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext private const val TAG = "DownloadDialogViewModel" class DownloadDialogViewModel(private val downloader: DownloaderV2) : ViewModel() { sealed interface SelectionState { data object Idle : SelectionState data class PlaylistSelection(val result: PlaylistResult) : SelectionState data class FormatSelection(val info: VideoInfo) : SelectionState } sealed interface SheetState { data object InputUrl : SheetState data class Configure(val urlList: List) : SheetState data class Loading(val taskKey: String, val job: Job) : SheetState data class Error(val action: Action, val throwable: Throwable) : SheetState } sealed interface SheetValue { data object Expanded : SheetValue data object Hidden : SheetValue } sealed interface Action { data object HideSheet : Action data class ShowSheet(val urlList: List? = null) : Action data class ProceedWithURLs(val urlList: List) : Action data object Reset : Action data class FetchPlaylist( val url: String, val preferences: DownloadUtil.DownloadPreferences, ) : Action data class FetchFormats( val url: String, val audioOnly: Boolean, val preferences: DownloadUtil.DownloadPreferences, ) : Action data class DownloadWithPreset( val urlList: List, val preferences: DownloadUtil.DownloadPreferences, ) : Action data class RunCommand( val url: String, val template: CommandTemplate, val preferences: DownloadUtil.DownloadPreferences, ) : Action data object Cancel : Action } private val mSelectionStateFlow: MutableStateFlow = MutableStateFlow(SelectionState.Idle) private val mSheetStateFlow: MutableStateFlow = MutableStateFlow(SheetState.InputUrl) private val mSheetValueFlow: MutableStateFlow = MutableStateFlow(SheetValue.Hidden) val selectionStateFlow = mSelectionStateFlow.asStateFlow() val sheetStateFlow = mSheetStateFlow.asStateFlow() val sheetValueFlow = mSheetValueFlow.asStateFlow() private val sheetState get() = sheetStateFlow.value fun postAction(action: Action) { with(action) { when (this) { is Action.ProceedWithURLs -> proceedWithUrls(this) is Action.FetchFormats -> fetchFormat(this) is Action.FetchPlaylist -> fetchPlaylist(this) is Action.DownloadWithPreset -> downloadWithPreset(urlList, preferences) is Action.RunCommand -> runCommand(url, template, preferences) Action.HideSheet -> hideDialog() is Action.ShowSheet -> showDialog(this) Action.Cancel -> cancel() Action.Reset -> resetSelectionState() } } } private fun proceedWithUrls(action: Action.ProceedWithURLs) { mSheetStateFlow.update { SheetState.Configure(action.urlList) } } private fun fetchPlaylist(action: Action.FetchPlaylist) { val (url, preferences) = action val job = viewModelScope.launch(Dispatchers.IO) { DownloadUtil.getPlaylistOrVideoInfo( playlistURL = url, downloadPreferences = preferences, ) .onSuccess { info -> withContext(Dispatchers.Main) { when (info) { is PlaylistResult -> { mSelectionStateFlow.update { SelectionState.PlaylistSelection(result = info) } } is VideoInfo -> { mSelectionStateFlow.update { SelectionState.FormatSelection(info = info) } } } hideDialog() } } .onFailure { th -> mSheetStateFlow.update { SheetState.Error(action = action, throwable = th) } } } mSheetStateFlow.update { SheetState.Loading(taskKey = "FetchPlaylist_$url", job = job) } } private fun fetchFormat(action: Action.FetchFormats) { val (url, audioOnly, preferences) = action val job = viewModelScope.launch(Dispatchers.IO) { DownloadUtil.fetchVideoInfoFromUrl( url = url, preferences = preferences.copy(extractAudio = audioOnly), taskKey = "FetchFormat_$url", ) .onSuccess { info -> withContext(Dispatchers.Main) { mSelectionStateFlow.update { SelectionState.FormatSelection(info = info) } hideDialog() } } .onFailure { th -> withContext(Dispatchers.Main) { mSheetStateFlow.update { SheetState.Error(action, throwable = th) } } } } mSheetStateFlow.update { SheetState.Loading(taskKey = "FetchFormat_$url", job = job) } } private fun downloadWithPreset( urlList: List, preferences: DownloadUtil.DownloadPreferences, ) { urlList.forEach { downloader.enqueue(Task(url = it, preferences = preferences)) } hideDialog() } private fun runCommand( url: String, template: CommandTemplate, preferences: DownloadUtil.DownloadPreferences, ) { val task = Task( url = url, type = Task.TypeInfo.CustomCommand(template = template), preferences = preferences, ) downloader.enqueue(task) } private fun hideDialog() { mSheetValueFlow.update { SheetValue.Hidden } when (sheetState) { is SheetState.Loading -> { cancel() } else -> {} } } private fun showDialog(action: Action.ShowSheet) { val urlList = action.urlList if (!urlList.isNullOrEmpty()) { mSheetStateFlow.update { SheetState.Configure(urlList) } } else { mSheetStateFlow.update { SheetState.InputUrl } } mSheetValueFlow.update { SheetValue.Expanded } } private fun cancel(): Boolean { return when (val state = sheetState) { is SheetState.Loading -> { val res = YoutubeDL.destroyProcessById(id = state.taskKey) if (res) { state.job.cancel() } return res } else -> false } } private fun resetSelectionState() { mSelectionStateFlow.update { SelectionState.Idle } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/downloadv2/configure/FormatPage.kt ================================================ package com.junkfood.seal.ui.page.downloadv2.configure import android.content.Intent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.spring import androidx.compose.animation.fadeOut import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Subtitles import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.FileDownload import androidx.compose.material.icons.outlined.Subtitles import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FabPosition import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RangeSliderState import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.junkfood.seal.R import com.junkfood.seal.download.DownloaderV2 import com.junkfood.seal.download.TaskFactory import com.junkfood.seal.ui.component.ClearButton import com.junkfood.seal.ui.component.ConfirmButton import com.junkfood.seal.ui.component.DismissButton import com.junkfood.seal.ui.component.FormatItem import com.junkfood.seal.ui.component.FormatSubtitle import com.junkfood.seal.ui.component.FormatVideoPreview import com.junkfood.seal.ui.component.PreferenceInfo import com.junkfood.seal.ui.component.SealDialog import com.junkfood.seal.ui.component.SealSearchBar import com.junkfood.seal.ui.component.SuggestedFormatItem import com.junkfood.seal.ui.component.TextButtonWithIcon import com.junkfood.seal.ui.component.VideoFilterChip import com.junkfood.seal.ui.page.download.VideoClipDialog import com.junkfood.seal.ui.page.download.VideoSelectionSlider import com.junkfood.seal.ui.page.settings.general.DialogCheckBoxItem import com.junkfood.seal.ui.theme.SealTheme import com.junkfood.seal.ui.theme.generateLabelColor import com.junkfood.seal.util.EXTRACT_AUDIO import com.junkfood.seal.util.Format import com.junkfood.seal.util.MERGE_MULTI_AUDIO_STREAM import com.junkfood.seal.util.PreferenceUtil.getBoolean import com.junkfood.seal.util.PreferenceUtil.getString import com.junkfood.seal.util.PreferenceUtil.updateString import com.junkfood.seal.util.SUBTITLE import com.junkfood.seal.util.SUBTITLE_LANGUAGE import com.junkfood.seal.util.SubtitleFormat import com.junkfood.seal.util.VIDEO_CLIP import com.junkfood.seal.util.VideoClip import com.junkfood.seal.util.VideoInfo import com.junkfood.seal.util.toHttpsUrl import kotlin.math.min import kotlin.math.roundToInt import kotlinx.coroutines.delay import org.koin.compose.koinInject private const val TAG = "FormatPage" private data class FormatConfig( val formatList: List, val videoClips: List, val splitByChapter: Boolean, val newTitle: String, val selectedSubtitles: List, val selectedAutoCaptions: List, ) @Composable fun FormatPage( modifier: Modifier = Modifier, videoInfo: VideoInfo, downloader: DownloaderV2 = koinInject(), onNavigateBack: () -> Unit = {}, ) { if (videoInfo.formats.isNullOrEmpty()) return val audioOnly = EXTRACT_AUDIO.getBoolean() val mergeAudioStream = MERGE_MULTI_AUDIO_STREAM.getBoolean() val subtitleLanguageRegex = SUBTITLE_LANGUAGE.getString() val downloadSubtitle = SUBTITLE.getBoolean() val initialSelectedSubtitles = if (downloadSubtitle) { videoInfo .run { subtitles.keys + automaticCaptions.keys } .filterWithRegex(subtitleLanguageRegex) } else { emptySet() } var showUpdateSubtitleDialog by remember { mutableStateOf(false) } var diffSubtitleLanguages by remember { mutableStateOf(emptySet()) } FormatPageImpl( modifier = modifier, videoInfo = videoInfo, onNavigateBack = onNavigateBack, audioOnly = audioOnly, mergeAudioStream = !audioOnly && mergeAudioStream, selectedSubtitleCodes = initialSelectedSubtitles, isClippingAvailable = VIDEO_CLIP.getBoolean() && (videoInfo.duration ?: .0) >= 0, ) { config -> with(config) { diffSubtitleLanguages = (selectedSubtitles + selectedAutoCaptions) .run { this - this.filterWithRegex(subtitleLanguageRegex) } .toSet() downloader.enqueue( TaskFactory.createWithConfigurations( videoInfo = videoInfo, formatList = formatList, videoClips = videoClips, splitByChapter = splitByChapter, newTitle = newTitle, selectedSubtitles = selectedSubtitles, selectedAutoCaptions = selectedAutoCaptions, ) ) if (diffSubtitleLanguages.isNotEmpty()) { showUpdateSubtitleDialog = true } else { onNavigateBack() } } } if (showUpdateSubtitleDialog) { UpdateSubtitleLanguageDialog( modifier = Modifier, languages = diffSubtitleLanguages, onDismissRequest = { showUpdateSubtitleDialog = false onNavigateBack() }, onConfirm = { SUBTITLE_LANGUAGE.updateString( (diffSubtitleLanguages + subtitleLanguageRegex).joinToString(separator = ",") { it } ) showUpdateSubtitleDialog = false onNavigateBack() }, ) } } private const val NOT_SELECTED = -1 @Preview @Composable fun FormatPagePreview() { val captionsMap = mapOf( "en-en" to listOf(SubtitleFormat(ext = "", url = "", name = "English from English")), "ja-en" to listOf(SubtitleFormat(ext = "", url = "", name = "Japanese from English")), "zh-Hans-en" to listOf( SubtitleFormat(ext = "", url = "", name = "Chinese (Simplified) from English") ), "zh-Hant-en" to listOf( SubtitleFormat(ext = "", url = "", name = "Chinese (Traditional) from English") ), ) val subMap = buildMap { put("en", listOf(SubtitleFormat(ext = "ass", url = "", name = "English"))) put("ja", listOf(SubtitleFormat(ext = "ass", url = "", name = "Japanese"))) } val videoInfo = VideoInfo( formats = buildList { repeat(7) { add(Format(formatId = "$it")) } repeat(7) { add(Format(formatId = "$it", vcodec = "avc1", acodec = "none")) } repeat(7) { add( Format( formatId = "$it", acodec = "aac", vcodec = "none", format = "251 - audio only (medium)", fileSizeApprox = 2000000.0, tbr = 128.0, ) ) } }, subtitles = subMap, automaticCaptions = captionsMap, requestedFormats = buildList { add( Format( formatId = "616", format = "616 - 1920x1080 (Premium)", acodec = "none", vcodec = "vp09.00.40.08", ext = "webm", ) ) add( Format( formatId = "251", format = "251 - audio only (medium)", acodec = "opus", vcodec = "none", ext = "webm", ) ) }, duration = 180.0, ) SealTheme { FormatPageImpl( videoInfo = videoInfo, isClippingAvailable = true, mergeAudioStream = true, selectedSubtitleCodes = setOf("en", "ja-en"), ) } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun FormatPageImpl( modifier: Modifier = Modifier, videoInfo: VideoInfo = VideoInfo(), audioOnly: Boolean = false, mergeAudioStream: Boolean = false, isClippingAvailable: Boolean = false, selectedSubtitleCodes: Set, onNavigateBack: () -> Unit = {}, onDownloadPressed: (FormatConfig) -> Unit = { _ -> }, ) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() if (videoInfo.formats.isNullOrEmpty()) return val videoOnlyFormats = videoInfo.formats.filter { it.vcodec != "none" && it.acodec == "none" }.reversed() val audioOnlyFormats = videoInfo.formats.filter { it.acodec != "none" && it.vcodec == "none" }.reversed() val videoAudioFormats = videoInfo.formats.filter { it.acodec != "none" && it.vcodec != "none" }.reversed() val duration = videoInfo.duration ?: 0.0 var videoOnlyItemLimit by remember { mutableIntStateOf(6) } var audioOnlyItemLimit by remember { mutableIntStateOf(6) } var videoAudioItemLimit by remember { mutableIntStateOf(6) } val isSuggestedFormatAvailable = !videoInfo.requestedFormats.isNullOrEmpty() || !videoInfo.requestedDownloads.isNullOrEmpty() var isSuggestedFormatSelected by remember { mutableStateOf(isSuggestedFormatAvailable) } var selectedVideoAudioFormat by remember { mutableIntStateOf(NOT_SELECTED) } var selectedVideoOnlyFormat by remember { mutableIntStateOf(NOT_SELECTED) } val selectedAudioOnlyFormats = remember { mutableStateListOf() } val context = LocalContext.current val uriHandler = LocalUriHandler.current val hapticFeedback = LocalHapticFeedback.current fun String?.share() = this?.let { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) context.startActivity( Intent.createChooser( Intent().apply { action = Intent.ACTION_SEND type = "text/plain" putExtra(Intent.EXTRA_TEXT, it) }, null, ), null, ) } var isClippingVideo by remember { mutableStateOf(false) } var isSplittingVideo by remember { mutableStateOf(false) } val isSplitByChapterAvailable = !videoInfo.chapters.isNullOrEmpty() val videoDurationRange = 0f..(videoInfo.duration?.toFloat() ?: 0f) var showVideoClipDialog by remember { mutableStateOf(false) } var showRenameDialog by remember { mutableStateOf(false) } var showSubtitleSelectionDialog by remember { mutableStateOf(false) } var videoClipDuration by remember { mutableStateOf(videoDurationRange) } var videoTitle by remember { mutableStateOf("") } val suggestedSubtitleMap: Map> = videoInfo.subtitles.takeIf { it.isNotEmpty() } ?: videoInfo.automaticCaptions.filterKeys { it.endsWith("-orig") } val otherSubtitleMap: Map> = videoInfo.subtitles + videoInfo.automaticCaptions - suggestedSubtitleMap.keys LaunchedEffect(isClippingVideo) { delay(200) videoClipDuration = videoDurationRange } val lazyGridState = rememberLazyGridState() val formatList: List by remember { derivedStateOf { mutableListOf().apply { if (isSuggestedFormatSelected) { videoInfo.requestedFormats?.let { addAll(it) } ?: videoInfo.requestedDownloads?.forEach { it.requestedFormats?.let { addAll(it) } } } else { selectedAudioOnlyFormats.forEach { index -> add(audioOnlyFormats.elementAt(index)) } videoAudioFormats.getOrNull(selectedVideoAudioFormat)?.let { add(it) } videoOnlyFormats.getOrNull(selectedVideoOnlyFormat)?.let { add(it) } } } } } val isFabExpanded by remember { derivedStateOf { lazyGridState.firstVisibleItemIndex > 0 } } val selectedSubtitles = remember { mutableStateListOf().apply { addAll(selectedSubtitleCodes) } } val selectedAutoCaptions = remember { mutableStateListOf() } Scaffold( modifier = modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { TopAppBar( title = { Text( text = stringResource(R.string.format_selection), style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp), ) }, scrollBehavior = scrollBehavior, navigationIcon = { IconButton(onClick = { onNavigateBack() }) { Icon(Icons.Outlined.Close, stringResource(R.string.close)) } }, ) }, floatingActionButton = { val isFormatSelected = isSuggestedFormatSelected || formatList.isNotEmpty() if (isFormatSelected) { ExtendedFloatingActionButton( onClick = { onDownloadPressed( FormatConfig( formatList = formatList, videoClips = if (isClippingVideo) listOf(VideoClip(videoClipDuration)) else emptyList(), splitByChapter = isSplittingVideo, newTitle = videoTitle, selectedSubtitles = selectedSubtitles, selectedAutoCaptions = selectedAutoCaptions, ) ) }, modifier = Modifier.padding(12.dp), icon = { Icon( imageVector = Icons.Outlined.FileDownload, contentDescription = null, modifier = Modifier.size(24.dp), ) }, text = { Text(stringResource(R.string.start_download)) }, expanded = isFabExpanded, ) } }, floatingActionButtonPosition = FabPosition.End, ) { paddingValues -> LazyVerticalGrid( modifier = Modifier.padding(paddingValues), state = lazyGridState, verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), columns = GridCells.Adaptive(150.dp), contentPadding = PaddingValues(8.dp), ) { videoInfo.run { item(span = { GridItemSpan(maxLineSpan) }) { FormatVideoPreview( modifier = Modifier.padding(horizontal = 8.dp), title = videoTitle.ifEmpty { title }, author = uploader ?: channel ?: uploaderId.toString(), thumbnailUrl = thumbnail.toHttpsUrl(), duration = duration.roundToInt(), isClippingVideo = isClippingVideo, isSplittingVideo = isSplittingVideo, isClippingAvailable = isClippingAvailable, isSplitByChapterAvailable = isSplitByChapterAvailable, onClippingToggled = { isClippingVideo = !isClippingVideo }, onSplittingToggled = { isSplittingVideo = !isSplittingVideo }, onRename = { showRenameDialog = true }, onOpenThumbnail = { uriHandler.openUri(thumbnail.toHttpsUrl()) }, ) } } item(span = { GridItemSpan(maxLineSpan) }) { var shouldUpdateClipDuration by remember { mutableStateOf(false) } Column { AnimatedVisibility(visible = isClippingVideo) { Column { val state = remember(isClippingVideo, showVideoClipDialog) { RangeSliderState( activeRangeStart = videoClipDuration.start, activeRangeEnd = videoClipDuration.endInclusive, valueRange = videoDurationRange, onValueChangeFinished = { shouldUpdateClipDuration = true }, ) } DisposableEffect(shouldUpdateClipDuration) { videoClipDuration = state.activeRangeStart..state.activeRangeEnd onDispose { shouldUpdateClipDuration = false } } VideoSelectionSlider( modifier = Modifier.fillMaxWidth(), state = state, onDiscard = { isClippingVideo = false }, onDurationClick = { showVideoClipDialog = true }, ) androidx.compose.material3.HorizontalDivider() } } AnimatedVisibility(visible = isSplittingVideo) { Column { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { Text( text = stringResource( id = R.string.split_video_msg, videoInfo.chapters?.size ?: 0, ), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 12.dp), ) Spacer(modifier = Modifier.weight(1f)) TextButtonWithIcon( onClick = { isSplittingVideo = false }, icon = Icons.Outlined.Delete, text = stringResource(id = R.string.discard), contentColor = MaterialTheme.colorScheme.error, ) } androidx.compose.material3.HorizontalDivider() } } } } if (suggestedSubtitleMap.isNotEmpty()) { item(span = { GridItemSpan(maxLineSpan) }) { Column(modifier = Modifier.fillMaxWidth()) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 12.dp).padding(horizontal = 12.dp), ) { Text( text = stringResource(id = R.string.subtitle_language), color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f), ) ClickableTextAction( visible = true, text = stringResource( id = androidx.appcompat.R.string .abc_activity_chooser_view_see_all ), ) { showSubtitleSelectionDialog = true } } LazyRow(modifier = Modifier.padding()) { for ((code, formats) in suggestedSubtitleMap) { item { VideoFilterChip( selected = selectedSubtitles.contains(code), onClick = { if (selectedSubtitles.contains(code)) { selectedSubtitles.remove(code) } else { selectedSubtitles.add(code) } }, label = formats.first().run { name ?: protocol ?: code }, ) } } } } } } if (isSuggestedFormatAvailable) { item(span = { GridItemSpan(maxLineSpan) }) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 12.dp, bottom = 4.dp).padding(horizontal = 12.dp), ) { FormatSubtitle(text = stringResource(R.string.suggested)) } } item(span = { GridItemSpan(maxLineSpan) }) { val onClick = { isSuggestedFormatSelected = true selectedAudioOnlyFormats.clear() selectedVideoAudioFormat = NOT_SELECTED selectedVideoOnlyFormat = NOT_SELECTED } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { SuggestedFormatItem( modifier = Modifier.weight(1f), videoInfo = videoInfo, selected = isSuggestedFormatSelected, onClick = onClick, ) } } } if (audioOnlyFormats.isNotEmpty()) item(span = { GridItemSpan(maxLineSpan) }) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 16.dp).padding(horizontal = 12.dp), ) { FormatSubtitle( text = stringResource(R.string.audio), color = MaterialTheme.colorScheme.secondary, modifier = Modifier.weight(1f).padding(vertical = 4.dp), ) ClickableTextAction( visible = audioOnlyItemLimit < audioOnlyFormats.size, text = stringResource(R.string.show_all_items, audioOnlyFormats.size), ) { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) audioOnlyItemLimit = Int.MAX_VALUE } } } itemsIndexed( audioOnlyFormats.subList( fromIndex = 0, toIndex = min(audioOnlyItemLimit, audioOnlyFormats.size), ) ) { index, formatInfo -> FormatItem( formatInfo = formatInfo, duration = duration, selected = selectedAudioOnlyFormats.contains(index), containerColor = MaterialTheme.colorScheme.secondaryContainer, outlineColor = MaterialTheme.colorScheme.secondary, onLongClick = { formatInfo.url.share() }, ) { if (selectedAudioOnlyFormats.contains(index)) { selectedAudioOnlyFormats.remove(index) } else { if (!mergeAudioStream) { selectedAudioOnlyFormats.clear() } isSuggestedFormatSelected = false selectedAudioOnlyFormats.add(index) } } } if (!audioOnly) { if (videoOnlyFormats.isNotEmpty()) item(span = { GridItemSpan(maxLineSpan) }) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 16.dp).padding(horizontal = 12.dp), ) { FormatSubtitle( text = stringResource(R.string.video_only), color = MaterialTheme.colorScheme.tertiary, modifier = Modifier.weight(1f).padding(vertical = 4.dp), ) ClickableTextAction( visible = videoOnlyItemLimit < videoOnlyFormats.size, text = stringResource(R.string.show_all_items, videoOnlyFormats.size), ) { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) videoOnlyItemLimit = Int.MAX_VALUE } } } itemsIndexed( videoOnlyFormats.subList(0, min(videoOnlyItemLimit, videoOnlyFormats.size)) ) { index, formatInfo -> FormatItem( formatInfo = formatInfo, duration = duration, selected = selectedVideoOnlyFormat == index, containerColor = MaterialTheme.colorScheme.tertiaryContainer, outlineColor = MaterialTheme.colorScheme.tertiary, onLongClick = { formatInfo.url.share() }, ) { selectedVideoOnlyFormat = if (selectedVideoOnlyFormat == index) NOT_SELECTED else { selectedVideoAudioFormat = NOT_SELECTED isSuggestedFormatSelected = false index } } } } if (videoAudioFormats.isNotEmpty()) { item(span = { GridItemSpan(maxLineSpan) }) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 16.dp).padding(horizontal = 12.dp), ) { FormatSubtitle( text = stringResource(R.string.video), modifier = Modifier.weight(1f).padding(vertical = 4.dp), ) ClickableTextAction( visible = videoAudioItemLimit < videoAudioFormats.size, text = stringResource(R.string.show_all_items, videoAudioFormats.size), ) { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) videoAudioItemLimit = Int.MAX_VALUE } } } itemsIndexed( videoAudioFormats.subList(0, min(videoAudioItemLimit, videoAudioFormats.size)) ) { index, formatInfo -> FormatItem( formatInfo = formatInfo, duration = duration, selected = selectedVideoAudioFormat == index, onLongClick = { formatInfo.url.share() }, ) { selectedVideoAudioFormat = if (selectedVideoAudioFormat == index) NOT_SELECTED else { selectedAudioOnlyFormats.clear() selectedVideoOnlyFormat = NOT_SELECTED isSuggestedFormatSelected = false index } } } } if (!audioOnly && audioOnlyFormats.isNotEmpty() && videoOnlyFormats.isNotEmpty()) item(span = { GridItemSpan(maxLineSpan) }) { PreferenceInfo( modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp), text = stringResource(R.string.abs_hint), applyPaddings = false, ) } item { Spacer(modifier = Modifier.height(64.dp)) } } } if (showVideoClipDialog) VideoClipDialog( onDismissRequest = { showVideoClipDialog = false }, initialValue = videoClipDuration, valueRange = videoDurationRange, onConfirm = { videoClipDuration = it }, ) if (showRenameDialog) RenameDialog( initialValue = videoTitle.ifEmpty { videoInfo.title }, onDismissRequest = { showRenameDialog = false }, ) { videoTitle = it } if (showSubtitleSelectionDialog) SubtitleSelectionDialog( suggestedSubtitles = suggestedSubtitleMap, autoCaptions = otherSubtitleMap, selectedSubtitles = selectedSubtitles, onDismissRequest = { showSubtitleSelectionDialog = false }, onConfirm = { subs, autoSubs -> selectedSubtitles.run { clear() addAll(subs) } showSubtitleSelectionDialog = false }, ) } @Composable private fun RenameDialog( initialValue: String, onDismissRequest: () -> Unit, onConfirm: (String) -> Unit, ) { var filename by remember { mutableStateOf(initialValue) } SealDialog( onDismissRequest = onDismissRequest, confirmButton = { ConfirmButton { onConfirm(filename) onDismissRequest() } }, dismissButton = { DismissButton { onDismissRequest() } }, title = { Text(text = stringResource(id = R.string.rename)) }, icon = { Icon(imageVector = Icons.Outlined.Edit, contentDescription = null) }, text = { Column { OutlinedTextField( modifier = Modifier.padding(horizontal = 24.dp), value = filename, onValueChange = { filename = it }, label = { Text(text = stringResource(id = R.string.title)) }, trailingIcon = { if (filename == initialValue) ClearButton { filename = "" } }, ) } }, ) } private fun (Map>).filterWithSearchText( searchText: String ): Map> { return this.filter { it.run { searchText.isBlank() || key.contains(searchText, ignoreCase = true) || value.any { format -> format.name?.contains(searchText, ignoreCase = true) ?: false } } } } private fun Map>.sortedWithSelection( selectedKeys: List ): Map> { return this.toList() .sortedWith { entry1, entry2 -> when { entry1.first in selectedKeys && entry2.first in selectedKeys -> entry1.compareTo(entry2) // Both in selectedKeys - equal priority entry1.first in selectedKeys -> -1 // str1 has priority entry2.first in selectedKeys -> 1 // str2 has priority else -> entry1.compareTo(entry2) } } .toMap() } /** * Prioritizes comparison of subtitle names (via `getSubtitleName()`) if available, otherwise * compares the `key` portion of the pairs. * * Examples: `zh` (Chinese) should be greater than `en` (English) according to their names */ private fun (Pair>).compareTo( other: (Pair>) ): Int { val (key, list) = this val (otherKey, otherList) = other val name = list.getSubtitleName() val otherName = otherList.getSubtitleName() return if (name != null && otherName != null) { name.compareTo(otherName) } else { key.compareTo(otherKey) } } private fun (List).getSubtitleName(): String? = firstOrNull()?.name @OptIn(ExperimentalFoundationApi::class) @Composable private fun SubtitleSelectionDialog( suggestedSubtitles: Map>, autoCaptions: Map>, selectedSubtitles: List, onDismissRequest: () -> Unit = {}, onConfirm: (subs: List, autoSubs: List) -> Unit = { _, _ -> }, ) { var searchText by remember { mutableStateOf("") } val selectedSubtitles = remember { mutableStateListOf().apply { addAll(selectedSubtitles) } } val selectedAutoCaptions = remember { mutableStateListOf() } val suggestedSubtitlesFiltered = suggestedSubtitles.filterWithSearchText(searchText).sortedWithSelection(selectedSubtitles) val autoCaptionsFiltered = autoCaptions.filterWithSearchText(searchText).sortedWithSelection(selectedSubtitles) SealDialog( containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, onDismissRequest = onDismissRequest, confirmButton = { ConfirmButton { onConfirm(selectedSubtitles, selectedAutoCaptions) } }, dismissButton = { DismissButton { onDismissRequest() } }, title = { Text(text = stringResource(id = R.string.subtitle_language)) }, icon = { Icon(imageVector = Icons.Outlined.Subtitles, contentDescription = null) }, text = { Column { if (autoCaptions.size + suggestedSubtitles.size > 5) { SealSearchBar( text = searchText, placeholderText = stringResource(R.string.search_in_subtitles), modifier = Modifier.padding(horizontal = 16.dp), ) { searchText = it } } LazyColumn(contentPadding = PaddingValues(vertical = 12.dp)) { if (suggestedSubtitlesFiltered.isNotEmpty()) { item { Text( text = stringResource(id = R.string.suggested), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp), ) } } for ((code, formats) in suggestedSubtitlesFiltered) { item(key = code) { DialogCheckBoxItem( modifier = Modifier.animateItem(), checked = selectedSubtitles.contains(code), onValueChange = { if (selectedSubtitles.contains(code)) { selectedSubtitles.remove(code) } else { selectedSubtitles.add(code) } }, text = formats.first().run { name ?: protocol ?: code }, ) } } if (autoCaptionsFiltered.isNotEmpty()) { item { Text( text = stringResource(id = R.string.auto_subtitle), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp), ) } for ((code, formats) in autoCaptionsFiltered) { item(key = code) { DialogCheckBoxItem( modifier = Modifier.animateItem(), checked = selectedAutoCaptions.contains(code), onValueChange = { if (selectedAutoCaptions.contains(code)) { selectedAutoCaptions.remove(code) } else { selectedAutoCaptions.add(code) } }, text = formats.first().run { name ?: protocol ?: code }, ) } } } } androidx.compose.material3.HorizontalDivider() } }, ) } @Preview @Composable private fun SubtitleSelectionDialogPreview() { val captionsMap = mapOf( "en-en" to listOf(SubtitleFormat(ext = "", url = "", name = "English from English")), "ja-en" to listOf(SubtitleFormat(ext = "", url = "", name = "Japanese from English")), "zh-Hans-en" to listOf( SubtitleFormat(ext = "", url = "", name = "Chinese (Simplified) from English") ), "zh-Hant-en" to listOf( SubtitleFormat(ext = "", url = "", name = "Chinese (Traditional) from English") ), ) val subMap = buildMap { put("en", listOf(SubtitleFormat(ext = "ass", url = "", name = "English"))) put("ja", listOf(SubtitleFormat(ext = "ass", url = "", name = "Japanese"))) } SealTheme { SubtitleSelectionDialog( suggestedSubtitles = subMap, autoCaptions = captionsMap, selectedSubtitles = listOf(), ) } } @Composable private fun ClickableTextAction( visible: Boolean, text: String, modifier: Modifier = Modifier, onClick: () -> Unit, ) { AnimatedVisibility(visible = visible, exit = fadeOut(animationSpec = spring())) { Text( text = text, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.titleSmall, modifier = modifier .clip(CircleShape) .clickable(onClick = onClick) .padding(vertical = 4.dp, horizontal = 12.dp), ) } } fun > T.filterWithRegex(subtitleLanguageRegex: String): Set { val regexGroup = subtitleLanguageRegex.split(',') return filter { language -> regexGroup.any { Regex(it).matchEntire(language) != null } }.toSet() } @OptIn(ExperimentalLayoutApi::class) @Composable @Preview fun UpdateSubtitleLanguageDialog( modifier: Modifier = Modifier, languages: Set = setOf("en", "ja"), onDismissRequest: () -> Unit = {}, onConfirm: () -> Unit = {}, ) { AlertDialog( onDismissRequest = onDismissRequest, title = { Text( text = stringResource(R.string.update_subtitle_languages), textAlign = TextAlign.Center, ) }, icon = { Icon(imageVector = Icons.Filled.Subtitles, contentDescription = null) }, text = { Column { Text(text = stringResource(R.string.update_language_msg)) Spacer(modifier = Modifier.height(24.dp)) FlowRow( horizontalArrangement = Arrangement.spacedBy(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { languages.forEach { Row(modifier = Modifier, verticalAlignment = Alignment.CenterVertically) { Box( modifier = Modifier.padding(end = 8.dp) .size(16.dp) .background( color = it.hashCode().generateLabelColor(), shape = CircleShape, ) .clearAndSetSemantics {} ) {} Text( text = it, modifier = Modifier, style = MaterialTheme.typography.bodySmall, ) } } } Spacer(modifier = Modifier.height(8.dp)) } }, confirmButton = { Button(onClick = onConfirm) { Text(text = stringResource(id = R.string.okay)) } }, dismissButton = { OutlinedButton(onClick = onDismissRequest) { Text(text = stringResource(id = R.string.no_thanks)) } }, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/downloadv2/configure/InputUrlDialog.kt ================================================ package com.junkfood.seal.ui.page.downloadv2.configure import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateContentSize import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.toggleable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowForward import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.AddLink import androidx.compose.material.icons.outlined.Cancel import androidx.compose.material.icons.outlined.ContentPaste import androidx.compose.material.icons.outlined.ContentPasteGo import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Link import androidx.compose.material3.Checkbox import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.SuggestionChip import androidx.compose.material3.SuggestionChipDefaults import androidx.compose.material3.Surface import androidx.compose.material3.SwipeToDismissBox import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.TriStateCheckbox import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import com.junkfood.seal.R import com.junkfood.seal.ui.component.ClearButton import com.junkfood.seal.ui.component.FilledButtonWithIcon import com.junkfood.seal.ui.component.OutlinedButtonWithIcon import com.junkfood.seal.ui.component.OutlinedDismissButton import com.junkfood.seal.ui.component.SealDialog import com.junkfood.seal.ui.page.downloadv2.configure.DownloadDialogViewModel.Action import com.junkfood.seal.ui.theme.ErrorTonalPalettes import com.junkfood.seal.util.findURLsFromString @Composable fun InputUrlPage( modifier: Modifier = Modifier, config: Config, onConfigUpdate: (Config) -> Unit, onActionPost: (Action) -> Unit, ) { val clipboardManager = LocalClipboardManager.current val urlList = remember { mutableStateListOf() } val savedLinks = remember(config) { mutableStateListOf() } LaunchedEffect(Unit) { clipboardManager.getText()?.let { urlList.clear() urlList.addAll(findURLsFromString(it.toString()).toSet()) } } LaunchedEffect(config) { savedLinks.addAll(config.savedLinks) } InputUrlPageImpl( modifier = modifier, urlListFromClipboard = urlList, savedLinks = savedLinks, onSaveLink = { savedLinks.add(it) }, onRemoveSavedLink = { savedLinks.remove(it) }, onActionPost = onActionPost, ) DisposableEffect(Unit) { onDispose { onConfigUpdate(config.copy(savedLinks = savedLinks.toSet())) } } } @Preview @Composable private fun InputUrlPreview() { val urlList = remember { mutableStateListOf().apply { repeat(20) { add("https://www.example$it.com/") } } } InputUrlPageImpl( urlListFromClipboard = listOf("https://www.example.com"), savedLinks = urlList, onSaveLink = { urlList.add(it) }, onRemoveSavedLink = { urlList.remove(it) }, ) {} } @Composable private fun InputUrlPageImpl( modifier: Modifier = Modifier, urlListFromClipboard: List, savedLinks: List = emptyList(), onSaveLink: (String) -> Unit = {}, onRemoveSavedLink: (String) -> Unit = {}, onActionPost: (Action) -> Unit, ) { var url by remember { mutableStateOf("") } var showPasteDialog by remember { mutableStateOf(false) } var showSavedUrlDialog by remember { mutableStateOf(false) } Column(modifier = modifier) { Header( modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp), title = stringResource(R.string.new_task), icon = Icons.Outlined.Add, ) OutlinedTextField( value = url, onValueChange = { url = it }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp).padding(horizontal = 32.dp), label = { Text(stringResource(R.string.video_url)) }, maxLines = 3, trailingIcon = { if (url.isNotEmpty()) { ClearButton { url = "" } } }, ) LazyRow( modifier = Modifier.padding(top = 8.dp).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(horizontal = 32.dp), ) { if (urlListFromClipboard.isNotEmpty()) { item(key = "paste url") { SuggestionChip( modifier = Modifier.animateItem(), onClick = { url = urlListFromClipboard.first() }, label = { Text(stringResource(R.string.paste_msg)) }, icon = { Icon( imageVector = Icons.Outlined.ContentPaste, contentDescription = null, modifier = Modifier.size(SuggestionChipDefaults.IconSize), ) }, ) } } if (urlListFromClipboard.size > 1) { item(key = "paste multiple url") { SuggestionChip( modifier = Modifier.animateItem(), onClick = { showPasteDialog = true }, label = { Text( stringResource( R.string.select_multiple_link, urlListFromClipboard.size, ) ) }, icon = { Icon( imageVector = Icons.Outlined.ContentPasteGo, contentDescription = null, modifier = Modifier.size(SuggestionChipDefaults.IconSize), ) }, ) } } item(key = "saved urls") { val addToSavedLinks by remember { derivedStateOf { url.isNotBlank() && !savedLinks.contains(url) } } if (addToSavedLinks || savedLinks.isNotEmpty()) { SuggestionChip( modifier = Modifier.animateItem(), onClick = { if (addToSavedLinks) onSaveLink(url) else { showSavedUrlDialog = true } }, label = { Row(modifier = Modifier.animateContentSize()) { if (addToSavedLinks) { Text( stringResource( R.string.add_to, stringResource(R.string.saved_urls), ) ) } else { Text(stringResource(R.string.saved_urls)) } } }, icon = { Icon( imageVector = Icons.Outlined.AddLink, contentDescription = null, modifier = Modifier.size(SuggestionChipDefaults.IconSize), ) }, ) } } } Row( modifier = Modifier.align(Alignment.End).padding(top = 24.dp).padding(horizontal = 32.dp) ) { OutlinedButtonWithIcon( modifier = Modifier.padding(horizontal = 12.dp), onClick = { onActionPost(Action.HideSheet) }, icon = Icons.Outlined.Cancel, text = stringResource(R.string.cancel), ) FilledButtonWithIcon( icon = Icons.AutoMirrored.Outlined.ArrowForward, text = stringResource(R.string.proceed), ) { onActionPost(Action.ProceedWithURLs(listOf(url))) } } } if (showPasteDialog) { URLSelectionDialog( urlListFromClipboard = urlListFromClipboard, onDismissRequest = { showPasteDialog = false }, onConfirm = { onActionPost(Action.ProceedWithURLs(it)) }, ) } if (showSavedUrlDialog) { SavedUrlDialogImpl( urls = savedLinks, onRemoveLink = onRemoveSavedLink, onActionPost = onActionPost, onDismissRequest = { showSavedUrlDialog = false }, ) } } @Composable private fun URLSelectionDialog( modifier: Modifier = Modifier, urlListFromClipboard: List, onDismissRequest: () -> Unit, onConfirm: (List) -> Unit, ) { val indexList = remember(urlListFromClipboard) { mutableStateListOf().apply { addAll(urlListFromClipboard.indices) } } SealDialog( modifier = modifier, onDismissRequest = onDismissRequest, title = { Text(stringResource(R.string.select_multiple_link, urlListFromClipboard.size)) }, icon = { Icon(Icons.Outlined.AddLink, null) }, confirmButton = { FilledButtonWithIcon( icon = Icons.AutoMirrored.Outlined.ArrowForward, text = stringResource(R.string.proceed), enabled = indexList.isNotEmpty(), ) { onConfirm(indexList.map { urlListFromClipboard[it] }) onDismissRequest() } }, dismissButton = { OutlinedButton(onClick = onDismissRequest) { Text(stringResource(R.string.cancel)) } }, text = { Box(modifier = Modifier.fillMaxSize()) { HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter)) LazyColumn(modifier = Modifier.padding(bottom = 48.dp).heightIn(max = 600.dp)) { itemsIndexed(urlListFromClipboard) { index, url -> DialogCheckBoxItemVariant(text = url, checked = indexList.contains(index)) { if (!it) { indexList -= index } else { indexList += index } } } } val checkBoxState = remember(indexList.size) { if (indexList.isEmpty()) { ToggleableState.Off } else if (indexList.size < urlListFromClipboard.size) { ToggleableState.Indeterminate } else { ToggleableState.On } } Column(modifier = Modifier.align(Alignment.BottomCenter)) { HorizontalDivider(modifier = Modifier) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).height(48.dp), ) { TriStateCheckbox( state = checkBoxState, onClick = { when (checkBoxState) { ToggleableState.On -> indexList.clear() ToggleableState.Off -> indexList.addAll(urlListFromClipboard.indices) ToggleableState.Indeterminate -> { indexList.clear() indexList.addAll(urlListFromClipboard.indices) } } }, ) Text(stringResource(R.string.select_all)) } } } }, ) } @Composable private fun DialogCheckBoxItemVariant( modifier: Modifier = Modifier, text: String, checked: Boolean, onValueChange: (Boolean) -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } Row( modifier = modifier .fillMaxWidth() .toggleable( value = checked, enabled = true, onValueChange = onValueChange, interactionSource = interactionSource, indication = LocalIndication.current, ) .padding(horizontal = 12.dp), verticalAlignment = Alignment.Top, ) { Checkbox( modifier = Modifier.clearAndSetSemantics {}, checked = checked, onCheckedChange = onValueChange, interactionSource = interactionSource, ) Text( modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), text = text, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis, ) } } @Composable private fun DialogSingleChoiceItemVariant( modifier: Modifier = Modifier, text: String, selected: Boolean, onSelect: () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } Row( modifier = modifier .fillMaxWidth() .selectable( selected = selected, enabled = true, onClick = onSelect, interactionSource = interactionSource, indication = LocalIndication.current, ) .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { RadioButton( selected = selected, onClick = onSelect, modifier = Modifier.clearAndSetSemantics {}, interactionSource = interactionSource, ) Text( modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), text = text, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis, ) } } @Composable fun SavedURLsDialog(modifier: Modifier = Modifier) {} @Preview @Composable private fun SavedUrlPreview() { val urls = remember { mutableStateListOf().apply { repeat(10) { add("https://www.example$it.com/") } } } SavedUrlDialogImpl(urls = urls, onActionPost = {}, onRemoveLink = { urls.remove(it) }) {} } @Composable private fun SavedUrlDialogImpl( modifier: Modifier = Modifier, urls: List, onRemoveLink: (String) -> Unit, onActionPost: (Action) -> Unit, onDismissRequest: () -> Unit, ) { if (urls.isEmpty()) { onDismissRequest() } var selectedUrl: String? by remember(urls.size) { mutableStateOf(null) } val hapticFeedback = LocalHapticFeedback.current SealDialog( modifier = modifier, icon = { Icon(Icons.Outlined.Link, contentDescription = null) }, title = { Text(stringResource(R.string.saved_urls)) }, onDismissRequest = onDismissRequest, dismissButton = { OutlinedDismissButton(onClick = onDismissRequest) }, confirmButton = { FilledButtonWithIcon( icon = Icons.AutoMirrored.Outlined.ArrowForward, text = stringResource(R.string.proceed), enabled = selectedUrl != null, ) { onActionPost(Action.ProceedWithURLs(listOf(selectedUrl!!))) } }, text = { Box(modifier = Modifier.animateContentSize()) { HorizontalDivider(Modifier.align(Alignment.TopCenter).zIndex(1f)) HorizontalDivider(Modifier.align(Alignment.BottomCenter).zIndex(1f)) LazyColumn(modifier = Modifier.heightIn(max = 600.dp)) { items(items = urls, key = { it }) { val dismissState = rememberSwipeToDismissBoxState() LaunchedEffect(dismissState.currentValue) { when (dismissState.currentValue) { SwipeToDismissBoxValue.EndToStart -> { hapticFeedback.performHapticFeedback(HapticFeedbackType.Confirm) onRemoveLink(it) } else -> {} } } val containerColor by animateColorAsState( when (dismissState.targetValue) { SwipeToDismissBoxValue.EndToStart -> ErrorTonalPalettes.accent1(80.0) else -> MaterialTheme.colorScheme.surfaceContainerHigh } ) val contentColor by animateColorAsState( when (dismissState.targetValue) { SwipeToDismissBoxValue.Settled -> MaterialTheme.colorScheme.onSurface else -> ErrorTonalPalettes.accent1(10.0) } ) SwipeToDismissBox( modifier = Modifier.animateItem(), state = dismissState, enableDismissFromEndToStart = true, enableDismissFromStartToEnd = false, backgroundContent = { Box(Modifier.fillMaxSize().background(containerColor)) { Icon( Icons.Outlined.Delete, null, modifier = Modifier.align(Alignment.CenterEnd) .padding(end = 16.dp) .size(28.dp), tint = contentColor, ) } }, ) { Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh) { DialogSingleChoiceItemVariant( text = it, selected = selectedUrl == it, onSelect = { selectedUrl = it }, ) } } } } } }, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/downloadv2/configure/PlaylistSelectionPage.kt ================================================ package com.junkfood.seal.ui.page.downloadv2.configure import androidx.activity.compose.BackHandler import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.selection.selectable import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.PlaylistAdd import androidx.compose.material.icons.outlined.Close import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.junkfood.seal.R import com.junkfood.seal.download.DownloaderV2 import com.junkfood.seal.download.TaskFactory import com.junkfood.seal.ui.common.HapticFeedback.slightHapticFeedback import com.junkfood.seal.ui.component.PlaylistItem import com.junkfood.seal.ui.component.SealModalBottomSheet import com.junkfood.seal.ui.component.SealModalBottomSheetM2Variant import com.junkfood.seal.ui.page.download.PlaylistSelectionDialog import com.junkfood.seal.ui.page.downloadv2.configure.DownloadDialogViewModel.SelectionState import com.junkfood.seal.ui.page.settings.format.AudioQuickSettingsDialog import com.junkfood.seal.ui.page.settings.format.VideoQuickSettingsDialog import com.junkfood.seal.util.AUDIO_CONVERSION_FORMAT import com.junkfood.seal.util.AUDIO_CONVERT import com.junkfood.seal.util.AUDIO_FORMAT import com.junkfood.seal.util.AUDIO_QUALITY import com.junkfood.seal.util.DownloadType.Audio import com.junkfood.seal.util.DownloadType.Video import com.junkfood.seal.util.DownloadUtil import com.junkfood.seal.util.PlaylistResult import com.junkfood.seal.util.PreferenceUtil.updateBoolean import com.junkfood.seal.util.PreferenceUtil.updateInt import com.junkfood.seal.util.USE_CUSTOM_AUDIO_PRESET import com.junkfood.seal.util.VIDEO_FORMAT import com.junkfood.seal.util.VIDEO_QUALITY import kotlinx.coroutines.launch import org.koin.compose.koinInject @OptIn(ExperimentalMaterial3Api::class) @Composable fun PlaylistSelectionPage( state: SelectionState.PlaylistSelection, downloader: DownloaderV2 = koinInject(), onDismissRequest: () -> Unit = {}, ) { var preferences by remember { mutableStateOf(DownloadUtil.DownloadPreferences.createFromPreferences()) } var showVideoPresetDialog by remember { mutableStateOf(false) } var showAudioPresetDialog by remember { mutableStateOf(false) } var taskList by remember { mutableStateOf(emptyList()) } val sheetState = androidx.compose.material.rememberModalBottomSheetState( initialValue = ModalBottomSheetValue.Hidden, skipHalfExpanded = true, ) LaunchedEffect(state) { sheetState.show() } val scope = rememberCoroutineScope() val onBack: () -> Unit = { scope.launch { sheetState.hide() }.invokeOnCompletion { onDismissRequest() } } BackHandler(onBack = onBack) var showConfigurationSheet by remember { mutableStateOf(false) } val configureSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) SealModalBottomSheetM2Variant(sheetState = sheetState, sheetGesturesEnabled = false) { PlaylistSelectionPageImpl(result = state.result, onDismissRequest = onBack) { taskList = it showConfigurationSheet = true } } val onDismissConfigurationSheet: () -> Unit = { scope .launch { configureSheetState.hide() } .invokeOnCompletion { showConfigurationSheet = false } } if (showConfigurationSheet) { SealModalBottomSheet( sheetState = configureSheetState, contentPadding = PaddingValues(), onDismissRequest = onDismissConfigurationSheet, ) { ConfigurePagePlaylistVariant( modifier = Modifier, initialDownloadType = Video, preferences = preferences, onPreferencesUpdate = { preferences = it }, onPresetEdit = { type -> when (type) { Audio -> showAudioPresetDialog = true Video -> showVideoPresetDialog = true else -> {} } }, onDismissRequest = onDismissConfigurationSheet, onDownload = { val preferences = preferences.copy(extractAudio = it == Audio) taskList .map { it.copy(task = it.task.copy(preferences = preferences)) } .forEach(downloader::enqueue) onDismissConfigurationSheet() onBack() }, ) } } if (showVideoPresetDialog) { var res by remember(preferences) { mutableIntStateOf(preferences.videoResolution) } var format by remember(preferences) { mutableIntStateOf(preferences.videoFormat) } VideoQuickSettingsDialog( videoResolution = res, videoFormatPreference = format, onResolutionSelect = { res = it }, onFormatSelect = { format = it }, onDismissRequest = { showVideoPresetDialog = false }, onSave = { VIDEO_FORMAT.updateInt(format) VIDEO_QUALITY.updateInt(res) preferences = DownloadUtil.DownloadPreferences.createFromPreferences() }, ) } if (showAudioPresetDialog) { var quality by remember(preferences) { mutableIntStateOf(preferences.audioQuality) } var customPreset by remember(preferences) { mutableStateOf(preferences.useCustomAudioPreset) } var conversionFmt by remember(preferences) { mutableIntStateOf(preferences.audioConvertFormat) } var convertAudio by remember(preferences) { mutableStateOf(preferences.convertAudio) } var preferredFormat by remember(preferences) { mutableIntStateOf(preferences.audioFormat) } AudioQuickSettingsDialog( modifier = Modifier, preferences = preferences, audioQuality = quality, onQualitySelect = { quality = it }, useCustomAudioPreset = customPreset, onCustomPresetToggle = { customPreset = it }, convertAudio = convertAudio, onConvertToggled = { convertAudio = it }, conversionFormat = conversionFmt, onConversionSelect = { conversionFmt = it }, preferredFormat = preferredFormat, onPreferredSelect = { preferredFormat = it }, onDismissRequest = { showAudioPresetDialog = false }, onSave = { AUDIO_QUALITY.updateInt(quality) USE_CUSTOM_AUDIO_PRESET.updateBoolean(customPreset) AUDIO_CONVERSION_FORMAT.updateInt(conversionFmt) AUDIO_CONVERT.updateBoolean(convertAudio) AUDIO_FORMAT.updateInt(preferredFormat) preferences = DownloadUtil.DownloadPreferences.createFromPreferences() }, ) } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun PlaylistSelectionPageImpl( result: PlaylistResult, onDismissRequest: () -> Unit = {}, onConfirmSelection: (List) -> Unit, ) { val view = LocalView.current val selectedItems = rememberSaveable( saver = listSaver, Int>( save = { if (it.isNotEmpty()) { it.toList() } else { emptyList() } }, restore = { it.toMutableStateList() }, ) ) { mutableStateListOf() } val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() var showDialog by remember { mutableStateOf(false) } val playlistCount = result.entries?.size ?: 0 Scaffold( modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { TopAppBar( title = { Text( text = if (selectedItems.isEmpty()) stringResource(id = R.string.download_playlist) else stringResource(id = R.string.selected_item_count) .format(selectedItems.size), style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp), ) }, navigationIcon = { IconButton(onClick = onDismissRequest) { Icon(Icons.Outlined.Close, stringResource(R.string.close)) } }, actions = { TextButton( modifier = Modifier.padding(end = 8.dp), onClick = { view.slightHapticFeedback() onConfirmSelection( TaskFactory.createWithPlaylistResult( playlistUrl = result.originalUrl ?: result.webpageUrl.toString(), indexList = selectedItems, playlistResult = result, preferences = DownloadUtil.DownloadPreferences.EMPTY, ) ) }, enabled = selectedItems.isNotEmpty(), ) { Text(text = stringResource(R.string.start_download)) } }, scrollBehavior = scrollBehavior, ) }, bottomBar = { Column( modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp).navigationBarsPadding(), verticalArrangement = Arrangement.Center, ) { HorizontalDivider(modifier = Modifier.fillMaxWidth()) Row(verticalAlignment = Alignment.CenterVertically) { Row( modifier = Modifier.selectable( selected = selectedItems.size == playlistCount && selectedItems.size != 0, indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = { view.slightHapticFeedback() if (selectedItems.size == playlistCount) selectedItems.clear() else { selectedItems.clear() selectedItems.addAll(1..playlistCount) } }, ), verticalAlignment = Alignment.CenterVertically, ) { Checkbox( modifier = Modifier.padding(16.dp), checked = selectedItems.size == playlistCount && selectedItems.size != 0, onCheckedChange = null, ) Text( text = stringResource(R.string.select_all), style = MaterialTheme.typography.labelLarge, ) } Spacer(modifier = Modifier.weight(1f)) IconButton( modifier = Modifier.padding(end = 4.dp), onClick = { view.slightHapticFeedback() showDialog = true }, ) { Icon( imageVector = Icons.AutoMirrored.Outlined.PlaylistAdd, contentDescription = stringResource(R.string.download_range_selection), ) } } } }, ) { paddings -> Column(modifier = Modifier.padding(paddings)) { LazyColumn { item { Text( modifier = Modifier.padding(16.dp), text = stringResource(R.string.download_selection_desc).format(result.title), style = MaterialTheme.typography.bodySmall, ) } itemsIndexed(items = result.entries ?: emptyList()) { indexFromZero, entry -> val index = indexFromZero + 1 TooltipBox( state = rememberTooltipState(), positionProvider = TooltipDefaults.rememberTooltipPositionProvider(), tooltip = { PlainTooltip { Text(text = entry.title ?: index.toString()) } }, ) { PlaylistItem( modifier = Modifier.padding(horizontal = 12.dp), imageModel = entry.thumbnails?.lastOrNull()?.url ?: "", title = entry.title ?: index.toString(), author = entry.channel ?: entry.uploader ?: result.channel ?: result.uploader, selected = selectedItems.contains(index), onClick = { if (selectedItems.contains(index)) selectedItems.remove(index) else selectedItems.add(index) }, ) } } } } } if (showDialog) { PlaylistSelectionDialog( playlistInfo = result, onDismissRequest = { showDialog = false }, onConfirm = { selectedItems.clear() selectedItems.addAll(it) }, ) } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/BasePreferencePage.kt ================================================ package com.junkfood.seal.ui.page.settings import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FabPosition import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import com.junkfood.seal.ui.component.BackButton @OptIn(ExperimentalMaterial3Api::class) @Composable fun BasePreferencePage( modifier: Modifier = Modifier, title: String, onBack: () -> Unit, topBar: @Composable (() -> Unit)? = null, bottomBar: @Composable (() -> Unit) = {}, snackbarHost: @Composable () -> Unit = {}, floatingActionButton: @Composable () -> Unit = {}, floatingActionButtonPosition: FabPosition = FabPosition.End, containerColor: Color = MaterialTheme.colorScheme.background, contentColor: Color = contentColorFor(containerColor), contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, content: @Composable (paddingValues: PaddingValues) -> Unit, ) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val topBar = topBar ?: { LargeTopAppBar( title = { Text(text = title) }, scrollBehavior = scrollBehavior, navigationIcon = { BackButton(onClick = onBack) }, ) } Scaffold( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = topBar, bottomBar = bottomBar, snackbarHost = snackbarHost, floatingActionButton = floatingActionButton, floatingActionButtonPosition = floatingActionButtonPosition, containerColor = containerColor, contentColor = contentColor, contentWindowInsets = contentWindowInsets, content = content, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/SettingsPage.kt ================================================ package com.junkfood.seal.ui.page.settings import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.PowerManager import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.AudioFile import androidx.compose.material.icons.rounded.BugReport import androidx.compose.material.icons.rounded.EnergySavingsLeaf import androidx.compose.material.icons.rounded.Folder import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Palette import androidx.compose.material.icons.rounded.SettingsApplications import androidx.compose.material.icons.rounded.SignalCellular4Bar import androidx.compose.material.icons.rounded.SignalWifi4Bar import androidx.compose.material.icons.rounded.Terminal import androidx.compose.material.icons.rounded.VideoFile import androidx.compose.material.icons.rounded.ViewComfy import androidx.compose.material.icons.rounded.VolunteerActivism import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.junkfood.seal.App import com.junkfood.seal.R import com.junkfood.seal.ui.common.Route import com.junkfood.seal.ui.common.intState import com.junkfood.seal.ui.component.BackButton import com.junkfood.seal.ui.component.PreferencesHintCard import com.junkfood.seal.ui.component.SettingItem import com.junkfood.seal.util.EXTRACT_AUDIO import com.junkfood.seal.util.PreferenceUtil.getBoolean import com.junkfood.seal.util.PreferenceUtil.updateInt import com.junkfood.seal.util.SHOW_SPONSOR_MSG @SuppressLint("BatteryLife") @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsPage(onNavigateBack: () -> Unit, onNavigateTo: (String) -> Unit) { val context = LocalContext.current val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager var showBatteryHint by remember { mutableStateOf( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { !pm.isIgnoringBatteryOptimizations(context.packageName) } else { false } ) } val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { data = Uri.parse("package:${context.packageName}") } } else { Intent() } val isActivityAvailable: Boolean = if (Build.VERSION.SDK_INT < 23) false else if (Build.VERSION.SDK_INT < 33) context.packageManager .queryIntentActivities(intent, PackageManager.MATCH_ALL) .isNotEmpty() else context.packageManager .queryIntentActivities( intent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_SYSTEM_ONLY.toLong()), ) .isNotEmpty() val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { showBatteryHint = !pm.isIgnoringBatteryOptimizations(context.packageName) } } val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val showSponsorMessage by SHOW_SPONSOR_MSG.intState LaunchedEffect(Unit) { SHOW_SPONSOR_MSG.updateInt(showSponsorMessage + 1) } val typography = MaterialTheme.typography Scaffold( modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { val overrideTypography = remember(typography) { typography.copy(headlineMedium = typography.displaySmall) } MaterialTheme(typography = overrideTypography) { LargeTopAppBar( title = { Text(text = stringResource(id = R.string.settings)) }, navigationIcon = { BackButton(onNavigateBack) }, scrollBehavior = scrollBehavior, expandedHeight = TopAppBarDefaults.LargeAppBarExpandedHeight + 24.dp, ) } }, ) { LazyColumn(modifier = Modifier, contentPadding = it) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { item { AnimatedVisibility( visible = showBatteryHint && isActivityAvailable, exit = shrinkVertically() + fadeOut(), ) { PreferencesHintCard( title = stringResource(R.string.battery_configuration), icon = Icons.Rounded.EnergySavingsLeaf, description = stringResource(R.string.battery_configuration_desc), ) { launcher.launch(intent) showBatteryHint = !pm.isIgnoringBatteryOptimizations(context.packageName) } } } } if (!showBatteryHint && showSponsorMessage > 30) item { PreferencesHintCard( title = stringResource(id = R.string.sponsor), icon = Icons.Rounded.VolunteerActivism, description = stringResource(id = R.string.sponsor_desc), ) { onNavigateTo(Route.DONATE) } } item { SettingItem( title = stringResource(id = R.string.general_settings), description = stringResource(id = R.string.general_settings_desc), icon = Icons.Rounded.SettingsApplications, ) { onNavigateTo(Route.GENERAL_DOWNLOAD_PREFERENCES) } } item { SettingItem( title = stringResource(id = R.string.download_directory), description = stringResource(id = R.string.download_directory_desc), icon = Icons.Rounded.Folder, ) { onNavigateTo(Route.DOWNLOAD_DIRECTORY) } } item { SettingItem( title = stringResource(id = R.string.format), description = stringResource(id = R.string.format_settings_desc), icon = if (EXTRACT_AUDIO.getBoolean()) Icons.Rounded.AudioFile else Icons.Rounded.VideoFile, ) { onNavigateTo(Route.DOWNLOAD_FORMAT) } } item { SettingItem( title = stringResource(id = R.string.network), description = stringResource(id = R.string.network_settings_desc), icon = if (App.connectivityManager.isActiveNetworkMetered) Icons.Rounded.SignalCellular4Bar else Icons.Rounded.SignalWifi4Bar, ) { onNavigateTo(Route.NETWORK_PREFERENCES) } } item { SettingItem( title = stringResource(id = R.string.custom_command), description = stringResource(id = R.string.custom_command_desc), icon = Icons.Rounded.Terminal, ) { onNavigateTo(Route.TEMPLATE) } } item { SettingItem( title = stringResource(id = R.string.look_and_feel), description = stringResource(id = R.string.display_settings), icon = Icons.Rounded.Palette, ) { onNavigateTo(Route.APPEARANCE) } } item { SettingItem( title = stringResource(id = R.string.interface_and_interaction), description = stringResource(id = R.string.settings_before_download), icon = Icons.Rounded.ViewComfy, ) { onNavigateTo(Route.INTERACTION) } } item { SettingItem( title = stringResource(R.string.trouble_shooting), description = stringResource(R.string.trouble_shooting_desc), icon = Icons.Rounded.BugReport, ) { onNavigateTo(Route.TROUBLESHOOTING) } } item { SettingItem( title = stringResource(id = R.string.about), description = stringResource(id = R.string.about_page), icon = Icons.Rounded.Info, ) { onNavigateTo(Route.ABOUT) } } } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/about/AboutPage.kt ================================================ package com.junkfood.seal.ui.page.settings.about import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.text.ClickableText import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.NewReleases import androidx.compose.material.icons.outlined.Update import androidx.compose.material.icons.outlined.UpdateDisabled import androidx.compose.material.icons.outlined.VolunteerActivism import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.UrlAnnotation import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import com.junkfood.seal.App import com.junkfood.seal.App.Companion.packageInfo import com.junkfood.seal.R import com.junkfood.seal.ui.component.BackButton import com.junkfood.seal.ui.component.ConfirmButton import com.junkfood.seal.ui.component.PreferenceItem import com.junkfood.seal.ui.component.PreferenceSwitchWithDivider import com.junkfood.seal.util.AUTO_UPDATE import com.junkfood.seal.util.PreferenceUtil import com.junkfood.seal.util.ToastUtil private const val releaseURL = "https://github.com/JunkFood02/Seal/releases" private const val repoUrl = "https://github.com/JunkFood02/Seal" const val weblate = "https://hosted.weblate.org/engage/seal/" const val YtdlpRepository = "https://github.com/yt-dlp/yt-dlp" private const val githubIssueUrl = "https://github.com/JunkFood02/Seal/issues" private const val telegramChannelUrl = "https://t.me/seal_app" private const val matrixSpaceUrl = "https://matrix.to/#/#seal-space:matrix.org" private const val githubSponsor = "https://github.com/sponsors/JunkFood02" private const val TAG = "AboutPage" @OptIn(ExperimentalMaterial3Api::class) @Composable fun AboutPage( onNavigateBack: () -> Unit, onNavigateToCreditsPage: () -> Unit, onNavigateToUpdatePage: () -> Unit, onNavigateToDonatePage: () -> Unit, ) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( rememberTopAppBarState(), canScroll = { true }, ) val context = LocalContext.current val clipboardManager = LocalClipboardManager.current // val configuration = LocalConfiguration.current // val screenDensity = configuration.densityDpi / 160f // val screenHeight = (configuration.screenHeightDp.toFloat() * screenDensity).roundToInt() // val screenWidth = (configuration.screenWidthDp.toFloat() * screenDensity).roundToInt() var isAutoUpdateEnabled by remember { mutableStateOf(PreferenceUtil.isAutoUpdateEnabled()) } val info = App.getVersionReport() val versionName = packageInfo.versionName // infoBuilder.append("App version: $versionName ($versionCode)\n") // .append("Device information: Android $release (API ${Build.VERSION.SDK_INT})\n") // .append("Supported ABIs: ${Build.SUPPORTED_ABIS.contentToString()}\n") // .append("\nScreen resolution: $screenHeight x $screenWidth") // .append("Yt-dlp Version: // ${YoutubeDL.version(context.applicationContext)}").toString() val uriHandler = LocalUriHandler.current fun openUrl(url: String) { uriHandler.openUri(url) } Scaffold( modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { LargeTopAppBar( title = { Text(modifier = Modifier, text = stringResource(id = R.string.about)) }, navigationIcon = { BackButton { onNavigateBack() } }, scrollBehavior = scrollBehavior, ) }, content = { LazyColumn(modifier = Modifier.padding(it)) { item { PreferenceItem( title = stringResource(R.string.readme), description = stringResource(R.string.readme_desc), icon = Icons.Outlined.Description, ) { openUrl(repoUrl) } } item { PreferenceItem( title = stringResource(R.string.release), description = stringResource(R.string.release_desc), icon = Icons.Outlined.NewReleases, ) { openUrl(releaseURL) } } /* item { PreferenceItem( title = stringResource(R.string.github_issue), description = stringResource(R.string.github_issue_desc), icon = Icons.Outlined.ContactSupport, ) { openUrl(githubIssueUrl) } }*/ item { PreferenceItem( title = stringResource(id = R.string.sponsor), description = stringResource(id = R.string.sponsor_desc), icon = Icons.Outlined.VolunteerActivism, ) { // openUrl(githubSponsor) onNavigateToDonatePage() } } item { PreferenceItem( title = stringResource(R.string.telegram_channel), description = telegramChannelUrl, icon = painterResource(id = R.drawable.icons8_telegram_app), ) { openUrl(telegramChannelUrl) } } item { PreferenceItem( title = stringResource(R.string.matrix_space), description = matrixSpaceUrl, icon = painterResource(id = R.drawable.icons8_matrix), ) { openUrl(matrixSpaceUrl) } } item { PreferenceItem( title = stringResource(id = R.string.credits), description = stringResource(id = R.string.credits_desc), icon = Icons.Outlined.AutoAwesome, ) { onNavigateToCreditsPage() } } item { PreferenceSwitchWithDivider( title = stringResource(R.string.auto_update), description = stringResource(R.string.check_for_updates_desc), icon = if (isAutoUpdateEnabled) Icons.Outlined.Update else Icons.Outlined.UpdateDisabled, isChecked = isAutoUpdateEnabled, isSwitchEnabled = !App.isFDroidBuild(), onClick = onNavigateToUpdatePage, onChecked = { isAutoUpdateEnabled = !isAutoUpdateEnabled PreferenceUtil.updateValue(AUTO_UPDATE, isAutoUpdateEnabled) }, ) } item { PreferenceItem( title = stringResource(R.string.version), description = versionName, icon = Icons.Outlined.Info, ) { clipboardManager.setText(AnnotatedString(info)) ToastUtil.makeToast(R.string.info_copied) } } item { PreferenceItem(title = "Package name", description = context.packageName) { clipboardManager.setText(AnnotatedString(context.packageName)) ToastUtil.makeToast(R.string.info_copied) } } } }, ) } @OptIn(ExperimentalTextApi::class) @Composable @Preview fun AutoUpdateUnavailableDialog(onDismissRequest: () -> Unit = {}) { val uriHandler = LocalUriHandler.current val hapticFeedback = LocalHapticFeedback.current val hyperLinkText = stringResource(id = R.string.switch_to_github_builds) val text = stringResource(id = R.string.auto_update_disabled_msg, "F-Droid", hyperLinkText) val annotatedString = buildAnnotatedString { append(text) val startIndex = text.indexOf(hyperLinkText) val endIndex = startIndex + hyperLinkText.length addUrlAnnotation( UrlAnnotation("https://github.com/JunkFood02/Seal/releases/latest"), start = startIndex, end = endIndex, ) addStyle( SpanStyle( color = MaterialTheme.colorScheme.tertiary, textDecoration = TextDecoration.Underline, ), start = startIndex, end = endIndex, ) } AlertDialog( onDismissRequest = onDismissRequest, confirmButton = { ConfirmButton(stringResource(id = R.string.got_it)) { onDismissRequest() } }, icon = { Icon(Icons.Outlined.UpdateDisabled, null) }, title = { Text( text = stringResource(id = R.string.feature_unavailable), textAlign = TextAlign.Center, ) }, text = { ClickableText( text = annotatedString, onClick = { index -> annotatedString.getUrlAnnotations(index, index).firstOrNull()?.let { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) uriHandler.openUri(it.item.url) } }, style = MaterialTheme.typography.bodyMedium.copy( MaterialTheme.colorScheme.onSurfaceVariant ), ) }, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/about/CreditsPage.kt ================================================ package com.junkfood.seal.ui.page.settings.about import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.dp import com.junkfood.seal.R import com.junkfood.seal.ui.component.BackButton import com.junkfood.seal.ui.component.CreditItem import com.junkfood.seal.ui.svg.DynamicColorImageVectors import com.junkfood.seal.ui.svg.drawablevectors.coder data class Credit(val title: String = "", val license: String? = null, val url: String = "") private const val GPL_V3 = "GNU General Public License v3.0" private const val GPL_V2 = "GNU General Public License v2.0" private const val LGPL_V2_1 = "GNU Lesser General Public License, version 2.1" private const val APACHE_V2 = "Apache License, Version 2.0" private const val UNLICENSE = "The Unlicense" private const val BSD = "BSD 3-Clause License" private const val youtubedlAndroidUrl = "https://github.com/yausername/youtubedl-android" private const val ytdlpUrl = "https://github.com/yt-dlp/yt-dlp" private const val readYou = "https://github.com/Ashinch/ReadYou" private const val dvd = "https://github.com/yausername/dvd" private const val icons8 = "https://icons8.com/" private const val materialIcon = "https://fonts.google.com/icons" private const val materialColor = "https://github.com/material-foundation/material-color-utilities" private const val monet = "https://github.com/Kyant0/Monet" private const val jetpack = "https://github.com/androidx/androidx" private const val coil = "https://github.com/coil-kt/coil" private const val mmkv = "https://github.com/Tencent/MMKV" private const val kotlin = "https://kotlinlang.org/" private const val okhttp = "https://github.com/square/okhttp" private const val accompanist = "https://github.com/google/accompanist" private const val aria2 = "https://github.com/aria2/aria2" private const val material3 = "https://m3.material.io/" private const val unDraw = "https://undraw.co/" private const val materialMotionCompose = "https://github.com/fornewid/material-motion-compose" private const val termux = "https://github.com/termux/termux-app" private const val FFmpeg = "https://ffmpeg.org/" @OptIn(ExperimentalMaterial3Api::class) @Composable fun CreditsPage(onNavigateBack: () -> Unit) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( rememberTopAppBarState(), canScroll = { true }, ) val creditsList = listOf( Credit("yt-dlp", UNLICENSE, ytdlpUrl), Credit("Read You", GPL_V3, readYou), Credit("youtubedl-android", GPL_V3, youtubedlAndroidUrl), Credit("Termux", GPL_V3, termux), Credit("FFmpeg", GPL_V2, FFmpeg), Credit("Android Jetpack", APACHE_V2, jetpack), Credit("Kotlin", APACHE_V2, kotlin), Credit("dvd", GPL_V3, dvd), Credit("Accompanist", APACHE_V2, accompanist), Credit("Material Design 3", APACHE_V2, material3), Credit("Material Icons", APACHE_V2, materialIcon), Credit("Monet", APACHE_V2, monet), Credit("Material color utilities", APACHE_V2, materialColor), Credit("MMKV", BSD, mmkv), Credit("Coil", APACHE_V2, coil), Credit("aria2", GPL_V2, aria2), Credit("OkHttp", APACHE_V2, okhttp), Credit("material-motion-compose", APACHE_V2, materialMotionCompose), Credit("unDraw", null, unDraw), Credit( "App icon by Icons8", "Universal Multimedia Licensing Agreement for Icons8", icons8, ), ) val uriHandler = LocalUriHandler.current fun openUrl(url: String) { uriHandler.openUri(url) } Scaffold( modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { LargeTopAppBar( title = { Text(modifier = Modifier, text = stringResource(id = R.string.credits)) }, navigationIcon = { BackButton { onNavigateBack() } }, scrollBehavior = scrollBehavior, ) }, content = { LazyColumn(modifier = Modifier.padding(it)) { item { Surface( modifier = Modifier.fillParentMaxWidth() .padding(horizontal = 12.dp, vertical = 12.dp) .clip(MaterialTheme.shapes.large) .clickable {} .clearAndSetSemantics {}, color = MaterialTheme.colorScheme.surfaceContainerLow, ) { val painter = rememberVectorPainter(image = DynamicColorImageVectors.coder()) Image( painter = painter, contentDescription = null, modifier = Modifier.padding(horizontal = 72.dp, vertical = 48.dp), ) } } items(creditsList) { item -> CreditItem(title = item.title, license = item.license) { openUrl(item.url) } } } }, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/about/SponsorPage.kt ================================================ package com.junkfood.seal.ui.page.settings.about import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyGridItemScope import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.House import androidx.compose.material.icons.outlined.Link import androidx.compose.material.icons.outlined.VolunteerActivism import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.junkfood.seal.R import com.junkfood.seal.ui.common.AsyncImageImpl import com.junkfood.seal.ui.component.BackButton import com.junkfood.seal.ui.component.PreferenceSubtitle import com.junkfood.seal.ui.component.SealModalBottomSheet import com.junkfood.seal.ui.component.SponsorItem import com.junkfood.seal.ui.component.gitHubAvatar import com.junkfood.seal.ui.component.gitHubProfile import com.junkfood.seal.ui.theme.SealTheme import com.junkfood.seal.util.PreferenceUtil.updateInt import com.junkfood.seal.util.SHOW_SPONSOR_MSG import com.junkfood.seal.util.SocialAccount import com.junkfood.seal.util.SocialAccounts import com.junkfood.seal.util.SponsorEntity import com.junkfood.seal.util.SponsorShip import com.junkfood.seal.util.SponsorUtil import com.junkfood.seal.util.Tier import com.junkfood.seal.util.ToastUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch private const val TAG = "SponsorPage" private const val SPONSORS = "Sponsors ☕️" private const val BACKERS = "Backers ❤️" private const val SUPPORTERS = "Supporters 💖" @OptIn(ExperimentalMaterial3Api::class) @Composable fun SponsorsPage(onNavigateBack: () -> Unit) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( rememberTopAppBarState(), canScroll = { true }, ) val uriHandler = LocalUriHandler.current val sponsorList = remember { mutableStateListOf() } val backerList = remember { mutableStateListOf() } val supporterList = remember { mutableStateListOf() } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) var viewingSponsorShip by remember { mutableStateOf(SponsorShip(sponsorEntity = SponsorEntity("login"))) } var showSheet by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() val onSponsorClick: (SponsorShip) -> Unit = { viewingSponsorShip = it showSheet = true scope.launch { delay(80) sheetState.show() } } LaunchedEffect(Unit) { launch(Dispatchers.IO) { SHOW_SPONSOR_MSG.updateInt(0) SponsorUtil.getSponsors() .onFailure { Log.e(TAG, "DonatePage: ", it) } .onSuccess { it.data.viewer.sponsorshipsAsMaintainer.nodes.run { sponsorList.addAll( filter { node -> (node.tier?.monthlyPriceInDollars ?: 0) in 5 until 10 } ) backerList.addAll( filter { node -> (node.tier?.monthlyPriceInDollars ?: 0) in 10 until 25 } ) supporterList.addAll( filter { node -> (node.tier?.monthlyPriceInDollars ?: 0) >= 25 } ) } } } } Scaffold( modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { LargeTopAppBar( title = { Text(modifier = Modifier, text = stringResource(id = R.string.sponsors)) }, navigationIcon = { BackButton { onNavigateBack() } }, scrollBehavior = scrollBehavior, ) }, content = { values -> LazyVerticalGrid( modifier = Modifier.padding(horizontal = 12.dp), columns = GridCells.Fixed(12), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), contentPadding = values, ) { if (supporterList.isNotEmpty()) { item(span = { GridItemSpan(maxLineSpan) }, key = SUPPORTERS) { PreferenceSubtitle( text = SUPPORTERS, contentPadding = PaddingValues(start = 12.dp, top = 24.dp, bottom = 12.dp), ) } items( span = { GridItemSpan(maxLineSpan / 3) }, items = supporterList, key = { it.sponsorEntity.login }, ) { sponsorShip -> SponsorItem(sponsorShip = sponsorShip) { onSponsorClick(sponsorShip) } } } if (backerList.isNotEmpty()) { item(span = { GridItemSpan(maxLineSpan) }, key = BACKERS) { PreferenceSubtitle( text = BACKERS, contentPadding = PaddingValues(start = 12.dp, top = 12.dp, bottom = 12.dp), ) } items( items = backerList, span = { GridItemSpan(maxLineSpan / 3) }, key = { it.sponsorEntity.login }, ) { sponsorShip -> SponsorItem(sponsorShip = sponsorShip) { onSponsorClick(sponsorShip) } } } if (sponsorList.isNotEmpty()) { item(span = { GridItemSpan(maxLineSpan) }, key = SPONSORS) { PreferenceSubtitle( text = SPONSORS, contentPadding = PaddingValues(start = 12.dp, top = 12.dp, bottom = 12.dp), ) } items( items = sponsorList, span = { GridItemSpan(maxLineSpan / 4) }, key = { it.sponsorEntity.login }, ) { sponsorShip -> SponsorItem(sponsorShip = sponsorShip) { onSponsorClick(sponsorShip) } } } item(span = { GridItemSpan(maxLineSpan) }) { Surface( shape = CardDefaults.shape, modifier = Modifier.padding(vertical = 12.dp), color = MaterialTheme.colorScheme.surfaceContainerLow, ) { Column(modifier = Modifier.padding(12.dp).fillMaxWidth()) { Text( modifier = Modifier.padding(bottom = 4.dp) .align(Alignment.CenterHorizontally), text = stringResource(id = R.string.msg_from_developer), style = MaterialTheme.typography.labelLarge, ) Row( modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), verticalAlignment = Alignment.Bottom, ) { AsyncImageImpl( model = gitHubAvatar("JunkFood02"), contentDescription = null, modifier = Modifier.size(48.dp) .aspectRatio(1f, true) .clip(CircleShape), contentScale = ContentScale.Crop, ) Column { Conversation( modifier = Modifier.padding(bottom = 12.dp), text = stringResource(id = R.string.sponsor_msg), ) Conversation( modifier = Modifier, text = stringResource(R.string.sponsor_msg2), ) } } Button( onClick = { uriHandler.openUri("https://github.com/sponsors/JunkFood02") }, contentPadding = ButtonDefaults.ButtonWithIconContentPadding, modifier = Modifier.align(Alignment.End), ) { Icon( modifier = Modifier.padding(end = 8.dp).size(ButtonDefaults.IconSize), imageVector = Icons.Outlined.VolunteerActivism, contentDescription = null, ) Text(text = stringResource(id = R.string.sponsor)) } } } } } if (showSheet) { SponsorDialog(sponsorShip = viewingSponsorShip, sheetState = sheetState) { scope.launch { sheetState.hide() }.invokeOnCompletion { showSheet = false } } } }, ) } @Composable @Preview fun SponsorPagePreview() { SponsorsPage {} } @Composable fun Conversation(modifier: Modifier = Modifier, text: String) { Row( modifier = modifier .padding(horizontal = 12.dp) .clip(MaterialTheme.shapes.extraLarge) .background(MaterialTheme.colorScheme.surfaceContainerHighest) .padding(horizontal = 20.dp, vertical = 16.dp) ) { Text(text = text, style = MaterialTheme.typography.bodyLarge) } } @Composable fun LazyGridItemScope.SponsorItem(sponsorShip: SponsorShip, onClick: () -> Unit) { SponsorItem( modifier = Modifier, userName = sponsorShip.sponsorEntity.name, userLogin = sponsorShip.sponsorEntity.login, onClick = onClick, ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun SponsorDialog(sponsorShip: SponsorShip, sheetState: SheetState, onDismissRequest: () -> Unit) { val amount = sponsorShip.tier?.monthlyPriceInDollars ?: 0 val tierText = if (amount in 5 until 10) { SPONSORS } else if (amount in 10 until 25) { BACKERS } else if (amount > 25) { SUPPORTERS } else { null } SealModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, contentPadding = PaddingValues(0.dp), ) { SponsorDialogContent( userLogin = sponsorShip.sponsorEntity.login, userName = sponsorShip.sponsorEntity.name, avatarUrl = gitHubAvatar(sponsorShip.sponsorEntity.login), tierText = tierText, website = sponsorShip.sponsorEntity.websiteUrl, socialLinks = sponsorShip.sponsorEntity.socialAccounts?.nodes?.map { it.url.toString() }, ) } } @Composable fun SponsorDialogContent( userLogin: String, userName: String?, avatarUrl: String, tierText: String? = null, website: String? = null, socialLinks: List? = null, ) { Column { Row( modifier = Modifier.fillMaxWidth() .padding(horizontal = 12.dp) .padding(bottom = 16.dp) .height(IntrinsicSize.Min) ) { AsyncImageImpl( modifier = Modifier.heightIn(max = 72.dp).aspectRatio(1f, true).clip(CircleShape), model = avatarUrl, contentDescription = null, contentScale = ContentScale.Crop, ) Column( modifier = Modifier.padding(vertical = 20.dp).padding(start = 12.dp), horizontalAlignment = Alignment.Start, ) { Text( text = userName ?: "@$userLogin", maxLines = 1, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, overflow = TextOverflow.Ellipsis, ) Text( text = tierText.toString(), maxLines = 1, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 2.dp), ) } } Column(modifier = Modifier.fillMaxWidth()) { androidx.compose.material3.HorizontalDivider() LinkItem(icon = Icons.Outlined.House, link = website ?: gitHubProfile(userLogin)) socialLinks?.forEach { LinkItem(icon = Icons.Outlined.Link, link = it) } } } } @Composable private fun LinkItem(modifier: Modifier = Modifier, icon: ImageVector, link: String) { val uriHandler = LocalUriHandler.current val clipboardManager = LocalClipboardManager.current val linkCopiedText = stringResource(id = R.string.link_copied) Row( modifier = modifier .fillMaxWidth() .clickable { uriHandler .runCatching { openUri(link) } .onFailure { clipboardManager.setText(AnnotatedString(link)) ToastUtil.makeToast(linkCopiedText) } } .padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = icon, contentDescription = null, modifier = Modifier.padding(horizontal = 16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Text(text = link, style = MaterialTheme.typography.titleSmall) } } @Preview @Composable private fun SponsorDialogContentPreview() { val sponsorShip = SponsorShip( sponsorEntity = SponsorEntity( "example", "example", "https://www.example.com", socialAccounts = SocialAccounts( buildList { repeat(4) { add( SocialAccount( displayName = "Example", url = "https://www.example.com", ) ) } } ), ), tier = Tier(10), ) SealTheme { Surface { SponsorDialogContent( userLogin = sponsorShip.sponsorEntity.login, userName = sponsorShip.sponsorEntity.name, avatarUrl = gitHubAvatar(sponsorShip.sponsorEntity.login), website = sponsorShip.sponsorEntity.websiteUrl, socialLinks = sponsorShip.sponsorEntity.socialAccounts?.nodes?.map { it.url.toString() }, ) } } } @OptIn(ExperimentalMaterial3Api::class) @Preview @Composable private fun SponsorDialogPreview() { val sheetState = with(LocalDensity.current) { SheetState( initialValue = SheetValue.Expanded, skipPartiallyExpanded = true, velocityThreshold = { 56.dp.toPx() }, positionalThreshold = { 125.dp.toPx() }, ) } val sponsorShip = SponsorShip( sponsorEntity = SponsorEntity( "example", "example", "https://www.example.com", socialAccounts = SocialAccounts( buildList { repeat(4) { add( SocialAccount( displayName = "Example", url = "https://www.example.com", ) ) } } ), ), tier = Tier(10), ) SponsorDialog(sponsorShip = sponsorShip, onDismissRequest = {}, sheetState = sheetState) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/about/UpdatePage.kt ================================================ package com.junkfood.seal.ui.page.settings.about import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Update import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.junkfood.seal.App import com.junkfood.seal.R import com.junkfood.seal.ui.common.intState import com.junkfood.seal.ui.component.BackButton import com.junkfood.seal.ui.component.PreferenceInfo import com.junkfood.seal.ui.component.PreferenceSingleChoiceItem import com.junkfood.seal.ui.component.PreferenceSubtitle import com.junkfood.seal.ui.component.PreferenceSwitchWithContainer import com.junkfood.seal.ui.page.UpdateDialog import com.junkfood.seal.util.AUTO_UPDATE import com.junkfood.seal.util.PRE_RELEASE import com.junkfood.seal.util.PreferenceUtil import com.junkfood.seal.util.PreferenceUtil.updateBoolean import com.junkfood.seal.util.PreferenceUtil.updateInt import com.junkfood.seal.util.STABLE import com.junkfood.seal.util.ToastUtil import com.junkfood.seal.util.UPDATE_CHANNEL import com.junkfood.seal.util.UpdateUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @Composable fun UpdatePage(onNavigateBack: () -> Unit) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( rememberTopAppBarState(), canScroll = { true }, ) var autoUpdate by remember { mutableStateOf(PreferenceUtil.isAutoUpdateEnabled()) } var updateChannel by UPDATE_CHANNEL.intState val scope = rememberCoroutineScope() val context = LocalContext.current var release by remember { mutableStateOf(UpdateUtil.Release()) } var showUpdateDialog by remember { mutableStateOf(false) } var showUnavailableDialog by remember { mutableStateOf(App.isFDroidBuild()) } Scaffold( modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { LargeTopAppBar( title = { Text(modifier = Modifier, text = stringResource(id = R.string.auto_update)) }, navigationIcon = { BackButton { onNavigateBack() } }, scrollBehavior = scrollBehavior, ) }, content = { paddings -> LazyColumn(modifier = Modifier.padding(paddings)) { item { PreferenceSwitchWithContainer( title = stringResource(id = R.string.enable_auto_update), icon = null, isChecked = autoUpdate, ) { autoUpdate = !autoUpdate AUTO_UPDATE.updateBoolean(autoUpdate) } } item { PreferenceSubtitle( modifier = Modifier.padding(horizontal = 4.dp), text = stringResource(id = R.string.update_channel), ) } item { PreferenceSingleChoiceItem( text = stringResource(id = R.string.stable_channel), selected = updateChannel == STABLE, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp), ) { updateChannel = STABLE UPDATE_CHANNEL.updateInt(updateChannel) } } item { PreferenceSingleChoiceItem( text = stringResource(id = R.string.pre_release_channel), selected = updateChannel == PRE_RELEASE, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp), ) { updateChannel = PRE_RELEASE UPDATE_CHANNEL.updateInt(updateChannel) } } item { var isLoading by remember { mutableStateOf(false) } Row( horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth(), ) { ProgressIndicatorButton( modifier = Modifier.padding(horizontal = 24.dp) .padding(top = 6.dp) .padding(bottom = 12.dp), text = stringResource(id = R.string.check_for_updates), icon = Icons.Outlined.Update, isLoading = isLoading, ) { if (!isLoading) scope.launch { runCatching { isLoading = true withContext(Dispatchers.IO) { UpdateUtil.checkForUpdate()?.let { release = it showUpdateDialog = true } ?: ToastUtil.makeToastSuspend( context.getString(R.string.app_up_to_date) ) } isLoading = false } .onFailure { it.printStackTrace() ToastUtil.makeToastSuspend( context.getString(R.string.app_update_failed) ) isLoading = false } } } } androidx.compose.material3.HorizontalDivider() } item { PreferenceInfo( modifier = Modifier.padding(horizontal = 4.dp), text = stringResource(id = R.string.update_channel_desc), ) } } }, ) if (showUpdateDialog) UpdateDialog(onDismissRequest = { showUpdateDialog = false }, release = release) if (showUnavailableDialog) { AutoUpdateUnavailableDialog { showUnavailableDialog = false onNavigateBack() } } } @Composable fun ProgressIndicatorButton( modifier: Modifier = Modifier, isLoading: Boolean = false, text: String, icon: ImageVector, onClick: () -> Unit, ) { FilledTonalButton( modifier = modifier, onClick = onClick, contentPadding = ButtonDefaults.ButtonWithIconContentPadding, ) { if (isLoading) Box(modifier = Modifier.size(18.dp)) { CircularProgressIndicator( modifier = Modifier.size(16.dp).align(Alignment.Center), strokeWidth = 3.dp, ) } else Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(18.dp)) Text(text = text, modifier = Modifier.padding(start = 8.dp)) } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/appearance/AppearancePreferences.kt ================================================ package com.junkfood.seal.ui.page.settings.appearance import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Colorize import androidx.compose.material.icons.outlined.DarkMode import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.LightMode import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.dp import com.google.accompanist.pager.HorizontalPagerIndicator import com.google.android.material.color.DynamicColors import com.junkfood.seal.R import com.junkfood.seal.download.Task import com.junkfood.seal.ui.common.LocalDarkTheme import com.junkfood.seal.ui.common.LocalDynamicColorSwitch import com.junkfood.seal.ui.common.LocalPaletteStyleIndex import com.junkfood.seal.ui.common.LocalSeedColor import com.junkfood.seal.ui.common.Route import com.junkfood.seal.ui.component.BackButton import com.junkfood.seal.ui.component.PreferenceItem import com.junkfood.seal.ui.component.PreferenceSwitch import com.junkfood.seal.ui.component.PreferenceSwitchWithDivider import com.junkfood.seal.ui.page.downloadv2.ActionButton import com.junkfood.seal.ui.page.downloadv2.CardStateIndicator import com.junkfood.seal.ui.page.downloadv2.VideoCardV2 import com.junkfood.seal.util.DarkThemePreference.Companion.OFF import com.junkfood.seal.util.DarkThemePreference.Companion.ON import com.junkfood.seal.util.PreferenceUtil import com.junkfood.seal.util.STYLE_MONOCHROME import com.junkfood.seal.util.STYLE_TONAL_SPOT import com.junkfood.seal.util.paletteStyles import com.junkfood.seal.util.toDisplayName import com.kyant.monet.LocalTonalPalettes import com.kyant.monet.PaletteStyle import com.kyant.monet.TonalPalettes import com.kyant.monet.TonalPalettes.Companion.toTonalPalettes import com.kyant.monet.a1 import com.kyant.monet.a2 import com.kyant.monet.a3 import io.material.hct.Hct import java.util.Locale import kotlinx.coroutines.Job private val ColorList = ((4..10) + (1..3)).map { it * 35.0 }.map { Color(Hct.from(it, 40.0, 40.0).toInt()) } private val DrawableList = listOf(R.drawable.sample, R.drawable.sample1, R.drawable.sample2, R.drawable.sample3) @OptIn(ExperimentalMaterial3Api::class) @Composable fun AppearancePreferences(onNavigateBack: () -> Unit, onNavigateTo: (String) -> Unit) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( rememberTopAppBarState(), canScroll = { true }, ) val index by remember { mutableIntStateOf(DrawableList.indices.random()) } val image by remember(index) { mutableIntStateOf(DrawableList[index]) } Scaffold( modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { LargeTopAppBar( title = { Text(modifier = Modifier, text = stringResource(id = R.string.look_and_feel)) }, navigationIcon = { BackButton(onNavigateBack) }, scrollBehavior = scrollBehavior, ) }, content = { Column(Modifier.verticalScroll(rememberScrollState()).padding(it)) { val downloadState = Task.DownloadState.Running(Job(), "", 0.8f) VideoCardV2( modifier = Modifier.padding(18.dp).clearAndSetSemantics {}, title = stringResource(R.string.video_title_sample_text), uploader = stringResource(R.string.video_creator_sample_text), thumbnailModel = image, stateIndicator = { CardStateIndicator(modifier = Modifier, downloadState = downloadState) }, actionButton = { ActionButton(modifier = Modifier, downloadState = downloadState) {} }, ) {} val pageCount = ColorList.size + 1 val pagerState = rememberPagerState( initialPage = if (LocalPaletteStyleIndex.current == STYLE_MONOCHROME) pageCount else ColorList.indexOf(Color(LocalSeedColor.current)).run { if (this == -1) 0 else this } ) { pageCount } HorizontalPager( modifier = Modifier.fillMaxWidth().clearAndSetSemantics {}, state = pagerState, contentPadding = PaddingValues(horizontal = 12.dp), ) { page -> if (page < pageCount - 1) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, ) { ColorButtons(ColorList[page]) } } else { // ColorButton for Monochrome theme val isSelected = LocalPaletteStyleIndex.current == STYLE_MONOCHROME && !LocalDynamicColorSwitch.current Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, ) { ColorButtonImpl( modifier = Modifier, isSelected = { isSelected }, tonalPalettes = Color.Black.toTonalPalettes(PaletteStyle.Monochrome), onClick = { PreferenceUtil.switchDynamicColor(enabled = false) PreferenceUtil.modifyThemeSeedColor( Color.Black.toArgb(), STYLE_MONOCHROME, ) }, ) } } } HorizontalPagerIndicator( pagerState = pagerState, pageCount = pageCount, modifier = Modifier.clearAndSetSemantics {} .align(Alignment.CenterHorizontally) .padding(vertical = 12.dp), activeColor = MaterialTheme.colorScheme.primary, inactiveColor = MaterialTheme.colorScheme.outlineVariant, indicatorHeight = 6.dp, indicatorWidth = 6.dp, ) if (DynamicColors.isDynamicColorAvailable()) { PreferenceSwitch( title = stringResource(id = R.string.dynamic_color), description = stringResource(id = R.string.dynamic_color_desc), icon = Icons.Outlined.Colorize, isChecked = LocalDynamicColorSwitch.current, onClick = { PreferenceUtil.switchDynamicColor() }, ) } val isDarkTheme = LocalDarkTheme.current.isDarkTheme() PreferenceSwitchWithDivider( title = stringResource(id = R.string.dark_theme), icon = if (isDarkTheme) Icons.Outlined.DarkMode else Icons.Outlined.LightMode, isChecked = isDarkTheme, description = LocalDarkTheme.current.getDarkThemeDesc(), onChecked = { PreferenceUtil.modifyDarkThemePreference(if (isDarkTheme) OFF else ON) }, onClick = { onNavigateTo(Route.DARK_THEME) }, ) PreferenceItem( title = stringResource(R.string.language), icon = Icons.Outlined.Language, description = Locale.getDefault().toDisplayName(), ) { onNavigateTo(Route.LANGUAGES) } } }, ) } @Composable fun RowScope.ColorButtons(color: Color) { paletteStyles.subList(STYLE_TONAL_SPOT, STYLE_MONOCHROME).forEachIndexed { index, style -> ColorButton(color = color, index = index, tonalStyle = style) } } @Composable fun RowScope.ColorButton( modifier: Modifier = Modifier, color: Color = Color.Green, index: Int = 0, tonalStyle: PaletteStyle = PaletteStyle.TonalSpot, ) { val tonalPalettes by remember { mutableStateOf(color.toTonalPalettes(tonalStyle)) } val isSelect = !LocalDynamicColorSwitch.current && LocalSeedColor.current == color.toArgb() && LocalPaletteStyleIndex.current == index ColorButtonImpl(modifier = modifier, tonalPalettes = tonalPalettes, isSelected = { isSelect }) { PreferenceUtil.switchDynamicColor(enabled = false) PreferenceUtil.modifyThemeSeedColor(color.toArgb(), index) } } @Composable fun RowScope.ColorButtonImpl( modifier: Modifier = Modifier, isSelected: () -> Boolean = { false }, tonalPalettes: TonalPalettes, cardColor: Color = MaterialTheme.colorScheme.surfaceContainer, containerColor: Color = MaterialTheme.colorScheme.primaryContainer, onClick: () -> Unit = {}, ) { val containerSize by animateDpAsState(targetValue = if (isSelected.invoke()) 28.dp else 0.dp) val iconSize by animateDpAsState(targetValue = if (isSelected.invoke()) 16.dp else 0.dp) Surface( modifier = modifier .padding(4.dp) .sizeIn(maxHeight = 80.dp, maxWidth = 80.dp, minHeight = 64.dp, minWidth = 64.dp) .weight(1f, false) .aspectRatio(1f), shape = RoundedCornerShape(16.dp), color = cardColor, onClick = onClick, ) { CompositionLocalProvider(LocalTonalPalettes provides tonalPalettes) { val color1 = 80.a1 val color2 = 90.a2 val color3 = 60.a3 Box(Modifier.fillMaxSize()) { Box( modifier = modifier .size(48.dp) .clip(CircleShape) .drawBehind { drawCircle(color1) } .align(Alignment.Center) ) { Surface( color = color2, modifier = Modifier.align(Alignment.BottomStart).size(24.dp), ) {} Surface( color = color3, modifier = Modifier.align(Alignment.BottomEnd).size(24.dp), ) {} Box( modifier = Modifier.align(Alignment.Center) .clip(CircleShape) .size(containerSize) .drawBehind { drawCircle(containerColor) } ) { Icon( imageVector = Icons.Outlined.Check, contentDescription = null, modifier = Modifier.size(iconSize).align(Alignment.Center), tint = MaterialTheme.colorScheme.onPrimaryContainer, ) } } } } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/appearance/DarkThemePreferences.kt ================================================ package com.junkfood.seal.ui.page.settings.appearance import android.os.Build import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Contrast import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import com.junkfood.seal.R import com.junkfood.seal.ui.common.LocalDarkTheme import com.junkfood.seal.ui.component.BackButton import com.junkfood.seal.ui.component.PreferenceSingleChoiceItem import com.junkfood.seal.ui.component.PreferenceSubtitle import com.junkfood.seal.ui.component.PreferenceSwitchVariant import com.junkfood.seal.util.DarkThemePreference.Companion.FOLLOW_SYSTEM import com.junkfood.seal.util.DarkThemePreference.Companion.OFF import com.junkfood.seal.util.DarkThemePreference.Companion.ON import com.junkfood.seal.util.PreferenceUtil @OptIn(ExperimentalMaterial3Api::class) @Composable fun DarkThemePreferences(onNavigateBack: () -> Unit) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( rememberTopAppBarState(), canScroll = { true }, ) val darkThemePreference = LocalDarkTheme.current val isHighContrastModeEnabled = darkThemePreference.isHighContrastModeEnabled Scaffold( modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { LargeTopAppBar( title = { Text(modifier = Modifier, text = stringResource(id = R.string.dark_theme)) }, navigationIcon = { BackButton { onNavigateBack() } }, scrollBehavior = scrollBehavior, ) }, content = { LazyColumn(modifier = Modifier, contentPadding = it) { if (Build.VERSION.SDK_INT >= 29) item { PreferenceSingleChoiceItem( text = stringResource(R.string.follow_system), selected = darkThemePreference.darkThemeValue == FOLLOW_SYSTEM, ) { PreferenceUtil.modifyDarkThemePreference(FOLLOW_SYSTEM) } } item { PreferenceSingleChoiceItem( text = stringResource(R.string.on), selected = darkThemePreference.darkThemeValue == ON, ) { PreferenceUtil.modifyDarkThemePreference(ON) } } item { PreferenceSingleChoiceItem( text = stringResource(R.string.off), selected = darkThemePreference.darkThemeValue == OFF, ) { PreferenceUtil.modifyDarkThemePreference(OFF) } } item { PreferenceSubtitle(text = stringResource(R.string.additional_settings)) } item { PreferenceSwitchVariant( title = stringResource(R.string.high_contrast), icon = Icons.Outlined.Contrast, isChecked = isHighContrastModeEnabled, onClick = { PreferenceUtil.modifyDarkThemePreference( isHighContrastModeEnabled = !isHighContrastModeEnabled ) }, ) } } }, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/appearance/LanguagesPage.kt ================================================ package com.junkfood.seal.ui.page.settings.appearance import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.provider.Settings import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos import androidx.compose.material.icons.outlined.Translate import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.os.LocaleListCompat import com.junkfood.seal.R import com.junkfood.seal.ui.component.BackButton import com.junkfood.seal.ui.component.PreferenceSingleChoiceItem import com.junkfood.seal.ui.component.PreferenceSubtitle import com.junkfood.seal.ui.component.PreferencesHintCard import com.junkfood.seal.ui.page.settings.about.weblate import com.junkfood.seal.ui.theme.SealTheme import com.junkfood.seal.util.LocaleLanguageCodeMap import com.junkfood.seal.util.PreferenceUtil import com.junkfood.seal.util.setLanguage import com.junkfood.seal.util.toDisplayName import java.util.Locale @Composable fun LanguagePage(onNavigateBack: () -> Unit = {}) { val selectedLocale by remember { mutableStateOf(Locale.getDefault()) } val scope = rememberCoroutineScope() val context = LocalContext.current val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply { val uri = Uri.fromParts("package", context.packageName, null) data = uri } } else { Intent() } val preferredLocales = remember { val defaultLocaleListCompat = LocaleListCompat.getDefault() val mLocaleSet = mutableSetOf() for (index in 0..defaultLocaleListCompat.size()) { val locale = defaultLocaleListCompat[index] if (locale != null) { mLocaleSet.add(locale) } } return@remember mLocaleSet } val supportedLocales = LocaleLanguageCodeMap.keys val suggestedLocales = remember(preferredLocales) { val localeSet = mutableSetOf() preferredLocales.forEach { desired -> val matchedLocale = supportedLocales.firstOrNull { supported -> LocaleListCompat.matchesLanguageAndScript( /* supported = */ desired, /* desired = */ supported, ) } if (matchedLocale != null) { localeSet.add(matchedLocale) } } return@remember localeSet } val otherLocales = supportedLocales - suggestedLocales val isSystemLocaleSettingsAvailable = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { context.packageManager .queryIntentActivities(intent, PackageManager.MATCH_ALL) .isNotEmpty() } else { false } LanguagePageImpl( onNavigateBack = onNavigateBack, suggestedLocales = suggestedLocales, otherLocales = otherLocales, isSystemLocaleSettingsAvailable = isSystemLocaleSettingsAvailable, onNavigateToSystemLocaleSettings = { if (isSystemLocaleSettingsAvailable) { context.startActivity(intent) } }, selectedLocale = selectedLocale, ) { PreferenceUtil.saveLocalePreference(it) setLanguage(it) } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun LanguagePageImpl( onNavigateBack: () -> Unit = {}, suggestedLocales: Set, otherLocales: Set, isSystemLocaleSettingsAvailable: Boolean = false, onNavigateToSystemLocaleSettings: () -> Unit, selectedLocale: Locale, onLanguageSelected: (Locale?) -> Unit = {}, ) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( rememberTopAppBarState(), canScroll = { true }, ) val uriHandler = LocalUriHandler.current Scaffold( modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { LargeTopAppBar( title = { Text(modifier = Modifier, text = stringResource(id = R.string.language)) }, navigationIcon = { BackButton { onNavigateBack() } }, scrollBehavior = scrollBehavior, ) }, content = { LazyColumn(modifier = Modifier, contentPadding = it) { item { PreferencesHintCard( title = stringResource(R.string.translate), description = stringResource(R.string.translate_desc), icon = Icons.Outlined.Translate, ) { uriHandler.openUri(weblate) } } if (suggestedLocales.isNotEmpty()) { item { PreferenceSubtitle(text = stringResource(id = R.string.suggested)) } if (!suggestedLocales.contains(Locale.getDefault())) { item { PreferenceSingleChoiceItem( text = stringResource(id = R.string.follow_system), selected = !suggestedLocales.contains(selectedLocale), ) { onLanguageSelected(null) } } } for (locale in suggestedLocales) { item { PreferenceSingleChoiceItem( text = locale.toDisplayName(), selected = selectedLocale == locale, ) { onLanguageSelected(locale) } } } } item { PreferenceSubtitle(text = stringResource(id = R.string.all_languages)) } for (locale in otherLocales) { item { PreferenceSingleChoiceItem( text = locale.toDisplayName(), selected = selectedLocale == locale, ) { onLanguageSelected(locale) } } } if (isSystemLocaleSettingsAvailable) { item { androidx.compose.material3.HorizontalDivider() Surface( modifier = Modifier.clickable(onClick = onNavigateToSystemLocaleSettings) ) { Row( modifier = Modifier.fillMaxWidth() .padding( PaddingValues(horizontal = 8.dp, vertical = 16.dp) ), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) { Text( text = stringResource(R.string.system_settings), maxLines = 1, style = MaterialTheme.typography.titleLarge.copy( fontSize = 20.sp ), color = MaterialTheme.colorScheme.onSurface, overflow = TextOverflow.Ellipsis, ) } Icon( imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos, contentDescription = null, modifier = Modifier.padding(end = 16.dp).size(18.dp), ) } } } } } }, ) } @Preview @Composable private fun LanguagePagePreview() { var language by remember { mutableStateOf(Locale.JAPANESE) } val map = setOf(Locale.forLanguageTag("en-US")) SealTheme { LanguagePageImpl( suggestedLocales = map, otherLocales = map + Locale.forLanguageTag("ja-JP"), isSystemLocaleSettingsAvailable = true, onNavigateToSystemLocaleSettings = { /*TODO*/ }, selectedLocale = language, ) { language = it } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/command/CommandTemplateDialog.kt ================================================ package com.junkfood.seal.ui.page.settings.command import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.EditNote import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import com.junkfood.seal.R import com.junkfood.seal.database.objects.CommandTemplate import com.junkfood.seal.database.objects.OptionShortcut import com.junkfood.seal.ui.component.AddButton import com.junkfood.seal.ui.component.ClearButton import com.junkfood.seal.ui.component.ConfirmButton import com.junkfood.seal.ui.component.LinkButton import com.junkfood.seal.ui.component.PasteFromClipBoardButton import com.junkfood.seal.ui.component.SealDialog import com.junkfood.seal.ui.component.SealTextField import com.junkfood.seal.ui.component.ShortcutChip import com.junkfood.seal.util.DatabaseUtil import kotlinx.coroutines.launch @Preview @Composable fun CommandTemplateDialog( commandTemplate: CommandTemplate = CommandTemplate(0, "", ""), newTemplate: Boolean = commandTemplate.id == 0, onDismissRequest: () -> Unit = {}, confirmationCallback: (Int) -> Unit = {}, ) { val context = LocalContext.current val clipboardManager = LocalClipboardManager.current val scope = rememberCoroutineScope() var templateText by remember { mutableStateOf(commandTemplate.template) } var templateName by remember { mutableStateOf(commandTemplate.name) } var isError by remember { mutableStateOf(false) } AlertDialog( icon = { Icon(if (newTemplate) Icons.Outlined.Add else Icons.Outlined.EditNote, null) }, title = { Text(stringResource(if (newTemplate) R.string.new_template else R.string.edit)) }, onDismissRequest = { onDismissRequest() }, properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = false), confirmButton = { ConfirmButton { if (templateName.isBlank() || templateName.isEmpty()) { isError = true } else { scope.launch { val id = if (newTemplate) { DatabaseUtil.insertTemplate( CommandTemplate(0, templateName, templateText) ) .toInt() } else { DatabaseUtil.updateTemplate( commandTemplate.copy( name = templateName, template = templateText, ) ) commandTemplate.id } confirmationCallback(id) onDismissRequest() } } } }, dismissButton = { TextButton(onClick = onDismissRequest) { Text(stringResource(R.string.dismiss)) } }, text = { val focusManager = LocalFocusManager.current val softwareKeyboardController = LocalSoftwareKeyboardController.current Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) { Text( text = stringResource(R.string.edit_template_desc), style = MaterialTheme.typography.bodyLarge, ) OutlinedTextField( modifier = Modifier.fillMaxWidth().padding(top = 16.dp), value = templateName, onValueChange = { templateName = it isError = false }, label = { Text(stringResource(R.string.template_label)) }, maxLines = 1, isError = isError, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), ) ProvideTextStyle( value = LocalTextStyle.current.merge(fontFamily = FontFamily.Monospace) ) { OutlinedTextField( modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), value = templateText, onValueChange = { templateText = it }, trailingIcon = { if (templateText.isEmpty()) PasteFromClipBoardButton { templateText = it } else ClearButton { templateText = "" } }, label = { Text(stringResource(R.string.custom_command_template)) }, maxLines = 12, minLines = 3, ) } LinkButton() } }, ) } @OptIn(ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class) @Composable fun OptionChipsDialog(onDismissRequest: () -> Unit = {}) { val scope = rememberCoroutineScope() val shortcuts by DatabaseUtil.getShortcuts().collectAsState(emptyList()) var text by remember { mutableStateOf("") } val addShortCuts = { scope.launch { text.removeSuffix(" ").run { if (shortcuts.find { it.option == this } == null) DatabaseUtil.insertShortcut(OptionShortcut(option = this)) text = "" } } } SealDialog( onDismissRequest = onDismissRequest, title = { Text(text = stringResource(id = R.string.edit_shortcuts)) }, icon = { Icon(Icons.Outlined.Edit, null) }, text = { Column { Text( text = stringResource(R.string.edit_shortcuts_desc), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(bottom = 12.dp).padding(horizontal = 24.dp), ) Column( modifier = Modifier.padding(horizontal = 16.dp) .requiredHeight(400.dp) .horizontalScroll(rememberScrollState()) .verticalScroll(rememberScrollState()) ) { FlowRow(modifier = Modifier.width(400.dp)) { shortcuts.forEach { item -> ShortcutChip( text = item.option, onRemove = { scope.launch { DatabaseUtil.deleteShortcut(item) } }, ) } } } val focusManager = LocalFocusManager.current val softwareKeyboardController = LocalSoftwareKeyboardController.current SealTextField( modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), value = text, onValueChange = { text = it }, trailingIcon = { AddButton(onClick = { addShortCuts() }, enabled = text.isNotEmpty()) }, keyboardActions = KeyboardActions( onDone = { addShortCuts() softwareKeyboardController?.hide() focusManager.moveFocus(FocusDirection.Down) } ), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), maxLines = 2, contentDescription = stringResource(id = R.string.shortcuts), ) } }, confirmButton = { TextButton(onClick = onDismissRequest) { Text(text = stringResource(id = androidx.appcompat.R.string.abc_action_mode_done)) } }, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/command/TemplateEditPage.kt ================================================ package com.junkfood.seal.ui.page.settings.command import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.DividerDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.junkfood.seal.R import com.junkfood.seal.database.objects.CommandTemplate import com.junkfood.seal.ui.component.AdjacentLabel import com.junkfood.seal.ui.component.BackButton import com.junkfood.seal.ui.component.ClearButton import com.junkfood.seal.ui.component.LinkButton import com.junkfood.seal.ui.component.PasteFromClipBoardButton import com.junkfood.seal.ui.component.ShortcutChip import com.junkfood.seal.ui.component.TextButtonWithIcon import com.junkfood.seal.util.DatabaseUtil import com.junkfood.seal.util.PreferenceUtil import kotlinx.coroutines.launch @OptIn( ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class, ) @Composable fun TemplateEditPage(onDismissRequest: () -> Unit, templateId: Int) { val scope = rememberCoroutineScope() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val commandTemplate = PreferenceUtil.templateListStateFlow.collectAsState().value.find { it.id == templateId } ?: CommandTemplate(0, "", "") var templateText by remember { mutableStateOf(commandTemplate.template) } var templateName by remember { mutableStateOf(commandTemplate.name) } val focusManager = LocalFocusManager.current val softwareKeyboardController = LocalSoftwareKeyboardController.current var isEditingShortcuts by remember { mutableStateOf(false) } Scaffold( modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { TopAppBar( title = { Text( text = stringResource( if (templateId <= 0) R.string.new_template else R.string.edit ), style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp), ) }, navigationIcon = { BackButton { onDismissRequest() } }, actions = { TextButton( modifier = Modifier.padding(end = 8.dp), onClick = { scope.launch { commandTemplate .copy(name = templateName, template = templateText) .run { if (id == 0) DatabaseUtil.insertTemplate(this) else DatabaseUtil.updateTemplate(this) } onDismissRequest() } }, enabled = templateName.isNotEmpty(), ) { Text( text = stringResource(androidx.appcompat.R.string.abc_action_mode_done) ) } }, scrollBehavior = scrollBehavior, ) }, ) { paddings -> LazyColumn(modifier = Modifier.padding(paddings), contentPadding = PaddingValues()) { item { val description = stringResource(R.string.template_label) Column(Modifier.padding(horizontal = 24.dp)) { AdjacentLabel(text = description, modifier = Modifier.padding(top = 12.dp)) OutlinedTextField( modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp), value = templateName, onValueChange = { templateName = it }, keyboardActions = KeyboardActions.Default, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), ) } } item { Column(Modifier.padding(horizontal = 24.dp)) { val description = stringResource(R.string.custom_command_template) AdjacentLabel(text = description, modifier = Modifier) ProvideTextStyle( value = LocalTextStyle.current.merge(fontFamily = FontFamily.Monospace) ) { OutlinedTextField( supportingText = { Text(text = stringResource(id = R.string.edit_template_desc)) }, modifier = Modifier.fillMaxWidth(), value = templateText, onValueChange = { templateText = it }, trailingIcon = { if (templateText.isEmpty()) PasteFromClipBoardButton { templateText = it } else ClearButton { templateText = "" } }, maxLines = 12, minLines = 6, ) } LinkButton(modifier = Modifier.padding(vertical = 12.dp)) HorizontalDivider( Modifier.fillMaxWidth() .padding(bottom = 24.dp) .size(DividerDefaults.Thickness) .clip(CircleShape), color = MaterialTheme.colorScheme.outlineVariant, ) } } item { Row(modifier = Modifier.fillMaxWidth().padding(start = 24.dp, end = 16.dp)) { Text( text = stringResource(R.string.shortcuts), modifier = Modifier.weight(1f).align(Alignment.CenterVertically), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.tertiary, ) TextButtonWithIcon( modifier = Modifier, onClick = { isEditingShortcuts = true }, icon = Icons.Outlined.Edit, text = stringResource(id = R.string.edit_shortcuts), contentColor = MaterialTheme.colorScheme.tertiary, ) } } item { val shortcuts by DatabaseUtil.getShortcuts().collectAsState(emptyList()) Column( modifier = Modifier.fillParentMaxWidth().horizontalScroll(rememberScrollState()) ) { FlowRow( modifier = Modifier.padding(horizontal = 8.dp).width(500.dp), // mainAxisSize = SizeMode.Expand, verticalArrangement = Arrangement.spacedBy(2.dp), ) { shortcuts.forEach { item -> ShortcutChip( text = item.option, onClick = { templateText = templateText.run { if (isEmpty()) item.option else this.removeSuffix(" ").removeSuffix("\n") + "\n${item.option}" } }, ) } } } } } } if (isEditingShortcuts) OptionChipsDialog { isEditingShortcuts = false } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/command/TemplateListPage.kt ================================================ package com.junkfood.seal.ui.page.settings.command import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.AssignmentReturn import androidx.compose.material.icons.outlined.BookmarkAdd import androidx.compose.material.icons.outlined.ContentPasteGo import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.DeleteSweep import androidx.compose.material.icons.outlined.HelpOutline import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material3.AlertDialog import androidx.compose.material3.BottomAppBar import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TriStateCheckbox import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.junkfood.seal.R import com.junkfood.seal.database.backup.BackupUtil import com.junkfood.seal.database.objects.CommandTemplate import com.junkfood.seal.ui.common.HapticFeedback.slightHapticFeedback import com.junkfood.seal.ui.common.intState import com.junkfood.seal.ui.component.BackButton import com.junkfood.seal.ui.component.ConfirmButton import com.junkfood.seal.ui.component.DismissButton import com.junkfood.seal.ui.component.HelpDialog import com.junkfood.seal.ui.component.PreferenceItemVariant import com.junkfood.seal.ui.component.PreferenceSwitchWithContainer import com.junkfood.seal.ui.component.TemplateItem import com.junkfood.seal.ui.page.settings.about.YtdlpRepository import com.junkfood.seal.util.CUSTOM_COMMAND import com.junkfood.seal.util.DatabaseUtil import com.junkfood.seal.util.PreferenceUtil import com.junkfood.seal.util.PreferenceUtil.getBoolean import com.junkfood.seal.util.TEMPLATE_ID import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext private const val TAG = "TemplateListPage" @OptIn(ExperimentalMaterial3Api::class) @Composable fun TemplateListPage(onNavigateBack: () -> Unit, onNavigateToEditPage: (Int) -> Unit) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( rememberTopAppBarState(), canScroll = { true }, ) val templates by PreferenceUtil.templateListStateFlow.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() val hapticFeedback = LocalHapticFeedback.current val view = LocalView.current val clipboardManager = LocalClipboardManager.current val context = LocalContext.current var showHelpDialog by remember { mutableStateOf(false) } var isMultiSelectEnabled by remember { mutableStateOf(false) } val selectedTemplates = remember { mutableStateListOf() } LaunchedEffect(isMultiSelectEnabled) { if (!isMultiSelectEnabled) { delay(200) selectedTemplates.clear() } } var showDeleteDialog by remember { mutableStateOf(false) } var showShortcutsDialog by remember { mutableStateOf(false) } var isCustomCommandEnabled by remember { mutableStateOf(CUSTOM_COMMAND.getBoolean()) } var selectedTemplateId by TEMPLATE_ID.intState val snackbarHostState = remember { SnackbarHostState() } BackHandler(isMultiSelectEnabled) { isMultiSelectEnabled = false } Scaffold( modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), snackbarHost = { SnackbarHost(modifier = Modifier, hostState = snackbarHostState) }, topBar = { LargeTopAppBar( title = { Text( modifier = Modifier, text = if (isMultiSelectEnabled) stringResource(id = R.string.custom_command_template) else stringResource(id = R.string.custom_command), maxLines = 1, overflow = TextOverflow.Ellipsis, ) }, navigationIcon = { BackButton { onNavigateBack() } }, actions = { var expanded by remember { mutableStateOf(false) } IconButton( onClick = { view.slightHapticFeedback() showHelpDialog = true } ) { Icon( imageVector = Icons.Outlined.HelpOutline, contentDescription = stringResource(id = R.string.how_does_it_work), ) } if (!isMultiSelectEnabled) { Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) { IconButton(onClick = { expanded = true }) { Icon( Icons.Outlined.MoreVert, contentDescription = stringResource(R.string.show_more_actions), ) } DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, ) { DropdownMenuItem( leadingIcon = { Icon(Icons.Outlined.ContentPasteGo, null) }, text = { Text(stringResource(R.string.export_to_clipboard)) }, onClick = { scope.launch { snackbarHostState.showSnackbar( context .getString(R.string.template_exported) .format(templates.size) ) } scope.launch { clipboardManager.setText( AnnotatedString(BackupUtil.exportTemplatesToJson()) ) expanded = false } }, ) DropdownMenuItem( leadingIcon = { Icon(Icons.Outlined.AssignmentReturn, null) }, text = { Text(stringResource(R.string.import_from_clipboard)) }, onClick = { scope.launch { expanded = false clipboardManager.getText()?.text?.let { if (it.isNotEmpty()) { val res = DatabaseUtil.importTemplatesFromJson(it) snackbarHostState.showSnackbar( context .getString(R.string.template_imported) .format(res) ) } } } }, ) } } } }, scrollBehavior = scrollBehavior, ) }, bottomBar = { val checkBoxState = remember(isMultiSelectEnabled, selectedTemplates.size) { when (selectedTemplates.size) { templates.size -> ToggleableState.On in 1 until templates.size -> ToggleableState.Indeterminate else -> ToggleableState.Off } } AnimatedVisibility( isMultiSelectEnabled, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut(), ) { BottomAppBar { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { TriStateCheckbox( state = checkBoxState, onClick = { view.slightHapticFeedback() when (checkBoxState) { ToggleableState.On -> selectedTemplates.clear() else -> selectedTemplates.run { clear() addAll(templates) } } }, modifier = Modifier.padding(start = 12.dp), ) Text( text = stringResource( id = R.string.selected_item_count, selectedTemplates.size, ), style = MaterialTheme.typography.labelLarge, modifier = Modifier.weight(1f), ) IconButton( onClick = { view.slightHapticFeedback() scope.launch { snackbarHostState.showSnackbar( context .getString(R.string.template_exported) .format(selectedTemplates.size) ) } scope.launch { clipboardManager.setText( AnnotatedString( BackupUtil.exportTemplatesToJson( templates = selectedTemplates, shortcuts = emptyList(), ) ) ) } }, enabled = selectedTemplates.isNotEmpty(), ) { Icon( imageVector = Icons.Outlined.ContentPasteGo, contentDescription = stringResource(id = R.string.export_to_clipboard), ) } IconButton( onClick = { view.slightHapticFeedback() showDeleteDialog = true }, enabled = selectedTemplates.isNotEmpty(), ) { Icon( imageVector = Icons.Outlined.DeleteSweep, contentDescription = stringResource(id = R.string.remove), ) } } } } }, ) { LazyColumn(modifier = Modifier, contentPadding = it) { item { PreferenceSwitchWithContainer( title = stringResource(R.string.use_custom_command), icon = null, isChecked = isCustomCommandEnabled, onClick = { isCustomCommandEnabled = !isCustomCommandEnabled PreferenceUtil.updateValue(CUSTOM_COMMAND, isCustomCommandEnabled) }, ) } items(templates) { commandTemplate -> TemplateItem( label = commandTemplate.name, template = commandTemplate.template, selected = selectedTemplateId == commandTemplate.id, isMultiSelectEnabled = isMultiSelectEnabled, onCheckedChange = { if (selectedTemplates.contains(commandTemplate)) selectedTemplates.remove(commandTemplate) else selectedTemplates.add(commandTemplate) }, checked = selectedTemplates.contains(commandTemplate), onClick = { onNavigateToEditPage(commandTemplate.id) }, onSelect = { selectedTemplateId = commandTemplate.id PreferenceUtil.encodeInt(TEMPLATE_ID, selectedTemplateId) }, onLongClick = { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) isMultiSelectEnabled = true selectedTemplates.add(commandTemplate) }, ) } if (!isMultiSelectEnabled) { item { PreferenceItemVariant( title = stringResource(id = R.string.new_template), icon = Icons.Outlined.Add, ) { onNavigateToEditPage(-1) } } item { PreferenceItemVariant( title = stringResource(id = R.string.edit_shortcuts), icon = Icons.Outlined.BookmarkAdd, ) { showShortcutsDialog = true } } } } } if (showDeleteDialog) { AlertDialog( onDismissRequest = { showDeleteDialog = false }, icon = { Icon(Icons.Outlined.Delete, null) }, title = { Text(stringResource(R.string.remove_template)) }, text = { Text( stringResource( R.string.remove_multiple_templates_msg, pluralStringResource( id = R.plurals.item_count, count = selectedTemplates.size, selectedTemplates.size, ), ) ) }, dismissButton = { DismissButton { showDeleteDialog = false } }, confirmButton = { ConfirmButton { scope.launch { withContext(Dispatchers.IO) { DatabaseUtil.deleteTemplates(selectedTemplates) } isMultiSelectEnabled = false } showDeleteDialog = false } }, ) } if (showShortcutsDialog) { OptionChipsDialog { showShortcutsDialog = false } } val uriHandler = LocalUriHandler.current if (showHelpDialog) { HelpDialog( text = stringResource(id = R.string.custom_command_usage_msg), onDismissRequest = { showHelpDialog = false }, dismissButton = null, ) { TextButton( onClick = { showHelpDialog = false uriHandler.openUri(YtdlpRepository) } ) { Text(text = stringResource(id = R.string.learn_more)) } } } LaunchedEffect(templates.size) { if (templates.isNotEmpty() && templates.find { it.id == selectedTemplateId } == null) { selectedTemplateId = templates.first().id PreferenceUtil.encodeInt(TEMPLATE_ID, selectedTemplateId) } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/directory/DirectoryPreferenceDialog.kt ================================================ package com.junkfood.seal.ui.page.settings.directory import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.SnippetFolder import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.junkfood.seal.R import com.junkfood.seal.ui.component.ConfirmButton import com.junkfood.seal.ui.component.DismissButton import com.junkfood.seal.ui.component.SealDialog import com.junkfood.seal.ui.page.settings.general.DialogCheckBoxItem @Composable fun DirectoryPreferenceDialog( onDismissRequest: () -> Unit = {}, isWebsiteSelected: Boolean, isPlaylistTitleSelected: Boolean, onConfirm: (isWebsiteSelected: Boolean, isPlaylistTitleSelected: Boolean) -> Unit = { _, _ -> }, ) { var website by remember { mutableStateOf(isWebsiteSelected) } var playlistTitle by remember { mutableStateOf(isPlaylistTitleSelected) } SealDialog( onDismissRequest = onDismissRequest, confirmButton = { ConfirmButton { onConfirm(website, playlistTitle) onDismissRequest() } }, dismissButton = { DismissButton { onDismissRequest() } }, title = { Text(text = stringResource(id = R.string.subdirectory)) }, icon = { Icon(imageVector = Icons.Outlined.SnippetFolder, contentDescription = null) }, text = { Column { Text( text = stringResource(id = R.string.subdirectory_desc), modifier = Modifier.padding(horizontal = 24.dp), // style = MaterialTheme.typography.bodyLarge ) Spacer(modifier = Modifier.height(8.dp)) androidx.compose.material3.HorizontalDivider( modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp) ) DialogCheckBoxItem( text = stringResource(id = R.string.website), checked = website, ) { website = !website } DialogCheckBoxItem( text = stringResource(id = R.string.playlist_title), checked = playlistTitle, ) { playlistTitle = !playlistTitle } androidx.compose.material3.HorizontalDivider( modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp) ) Spacer(modifier = Modifier.height(4.dp)) val dirStr = StringBuilder(".../").run { if (website) append("website/") if (playlistTitle) append("playlist_title/") append("file_name") } Text( text = stringResource(R.string.subdirectory_hint) + "\n" + dirStr, modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), // style = MaterialTheme.typography.labelMedium, ) } }, ) } @Preview @Composable private fun DirectoryPreferenceDialogPreview() { DirectoryPreferenceDialog( onDismissRequest = {}, isWebsiteSelected = false, isPlaylistTitleSelected = false, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/directory/DownloadDirectoryPreferences.kt ================================================ @file:OptIn(ExperimentalPermissionsApi::class) package com.junkfood.seal.ui.page.settings.directory import android.Manifest import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build import android.os.Environment import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.OpenInNew import androidx.compose.material.icons.filled.SdCardAlert import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.FolderDelete import androidx.compose.material.icons.outlined.FolderOpen import androidx.compose.material.icons.outlined.FolderSpecial import androidx.compose.material.icons.outlined.LibraryMusic import androidx.compose.material.icons.outlined.SdCard import androidx.compose.material.icons.outlined.SnippetFolder import androidx.compose.material.icons.outlined.Spellcheck import androidx.compose.material.icons.outlined.TabUnselected import androidx.compose.material.icons.outlined.VideoLibrary import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.rememberPermissionState import com.junkfood.seal.App import com.junkfood.seal.R import com.junkfood.seal.ui.common.booleanState import com.junkfood.seal.ui.common.stringState import com.junkfood.seal.ui.component.BackButton import com.junkfood.seal.ui.component.ConfirmButton import com.junkfood.seal.ui.component.DialogSingleChoiceItem import com.junkfood.seal.ui.component.DismissButton import com.junkfood.seal.ui.component.LinkButton import com.junkfood.seal.ui.component.OutlinedButtonChip import com.junkfood.seal.ui.component.PreferenceInfo import com.junkfood.seal.ui.component.PreferenceItem import com.junkfood.seal.ui.component.PreferenceSubtitle import com.junkfood.seal.ui.component.PreferenceSwitch import com.junkfood.seal.ui.component.PreferenceSwitchWithDivider import com.junkfood.seal.ui.component.PreferencesHintCard import com.junkfood.seal.ui.component.SealDialog import com.junkfood.seal.util.COMMAND_DIRECTORY import com.junkfood.seal.util.CUSTOM_COMMAND import com.junkfood.seal.util.CUSTOM_OUTPUT_TEMPLATE import com.junkfood.seal.util.DownloadUtil import com.junkfood.seal.util.FileUtil import com.junkfood.seal.util.FileUtil.getConfigDirectory import com.junkfood.seal.util.FileUtil.getExternalTempDir import com.junkfood.seal.util.OUTPUT_TEMPLATE import com.junkfood.seal.util.PRIVATE_DIRECTORY import com.junkfood.seal.util.PreferenceUtil import com.junkfood.seal.util.PreferenceUtil.getBoolean import com.junkfood.seal.util.PreferenceUtil.getString import com.junkfood.seal.util.PreferenceUtil.updateBoolean import com.junkfood.seal.util.PreferenceUtil.updateString import com.junkfood.seal.util.RESTRICT_FILENAMES import com.junkfood.seal.util.SDCARD_DOWNLOAD import com.junkfood.seal.util.SDCARD_URI import com.junkfood.seal.util.SUBDIRECTORY_EXTRACTOR import com.junkfood.seal.util.SUBDIRECTORY_PLAYLIST_TITLE import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext private const val ytdlpOutputTemplateReference = "https://github.com/yt-dlp/yt-dlp#output-template" private val PublicDownloadsDirectory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).path private val PublicDocumentDirectory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS).path private const val ytdlpFilesystemReference = "https://github.com/yt-dlp/yt-dlp#filesystem-options" private fun String.isValidDirectory(): Boolean { return isEmpty() || contains(PublicDownloadsDirectory) || contains(PublicDocumentDirectory) } enum class Directory { AUDIO, VIDEO, SDCARD, CUSTOM_COMMAND, } @OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) @Composable fun DownloadDirectoryPreferences(onNavigateBack: () -> Unit) { val uriHandler = LocalUriHandler.current val clipboardManager = LocalClipboardManager.current val scope = rememberCoroutineScope() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( rememberTopAppBarState(), canScroll = { true }, ) val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } var showSubdirectoryDialog by remember { mutableStateOf(false) } var isPrivateDirectoryEnabled by remember { mutableStateOf(PRIVATE_DIRECTORY.getBoolean()) } var videoDirectoryText by remember(isPrivateDirectoryEnabled) { mutableStateOf( if (!isPrivateDirectoryEnabled) App.videoDownloadDir else App.privateDownloadDir ) } var audioDirectoryText by remember(isPrivateDirectoryEnabled) { mutableStateOf( if (!isPrivateDirectoryEnabled) App.audioDownloadDir else App.privateDownloadDir ) } var sdcardUri by remember { mutableStateOf(SDCARD_URI.getString()) } var customCommandDirectory by COMMAND_DIRECTORY.stringState var sdcardDownload by remember { mutableStateOf(SDCARD_DOWNLOAD.getBoolean()) } var showClearTempDialog by remember { mutableStateOf(false) } var showCustomCommandDirectoryDialog by remember { mutableStateOf(false) } var editingDirectory by remember { mutableStateOf(Directory.VIDEO) } val isCustomCommandEnabled by remember { mutableStateOf(CUSTOM_COMMAND.getBoolean()) } var showOutputTemplateDialog by remember { mutableStateOf(false) } val storagePermission = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE) val showDirectoryAlert = Build.VERSION.SDK_INT >= 30 && !Environment.isExternalStorageManager() && (!audioDirectoryText.isValidDirectory() || !videoDirectoryText.isValidDirectory() || !customCommandDirectory.isValidDirectory()) val launcher = rememberLauncherForActivityResult( object : ActivityResultContracts.OpenDocumentTree() { override fun createIntent(context: Context, input: Uri?): Intent { return (super.createIntent(context, input)).apply { flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION } } } ) { it?.let { uri -> App.updateDownloadDir(uri, editingDirectory) if (editingDirectory != Directory.SDCARD) { val path = FileUtil.getRealPath(uri) when (editingDirectory) { Directory.AUDIO -> { audioDirectoryText = path } Directory.VIDEO -> { videoDirectoryText = path } Directory.SDCARD -> { sdcardUri = uri.toString() } Directory.CUSTOM_COMMAND -> { customCommandDirectory = path } } } } } fun openDirectoryChooser(directory: Directory = Directory.VIDEO) { editingDirectory = directory if (Build.VERSION.SDK_INT > 29 || storagePermission.status == PermissionStatus.Granted) launcher.launch(null) else storagePermission.launchPermissionRequest() } Scaffold( modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), snackbarHost = { SnackbarHost(modifier = Modifier.systemBarsPadding(), hostState = snackbarHostState) }, topBar = { LargeTopAppBar( title = { Text( modifier = Modifier, text = stringResource(id = R.string.download_directory), ) }, navigationIcon = { BackButton { onNavigateBack() } }, scrollBehavior = scrollBehavior, ) }, ) { LazyColumn(modifier = Modifier, contentPadding = it) { if (isCustomCommandEnabled) item { PreferenceInfo(text = stringResource(id = R.string.custom_command_enabled_hint)) } if (showDirectoryAlert) item { PreferencesHintCard( title = stringResource(R.string.permission_issue), description = stringResource(R.string.permission_issue_desc), icon = Icons.Filled.SdCardAlert, ) { if ( Build.VERSION.SDK_INT >= 30 && !Environment.isExternalStorageManager() ) { Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK data = Uri.parse("package:" + context.packageName) if (resolveActivity(context.packageManager) != null) context.startActivity(this) } } } } item { PreferenceSubtitle(text = stringResource(R.string.general_settings)) } if (!isCustomCommandEnabled) { item { PreferenceItem( title = stringResource(id = R.string.video_directory), description = videoDirectoryText, enabled = !isPrivateDirectoryEnabled && !sdcardDownload, icon = Icons.Outlined.VideoLibrary, ) { openDirectoryChooser(directory = Directory.VIDEO) } } item { PreferenceItem( title = stringResource(id = R.string.audio_directory), description = audioDirectoryText, enabled = !isPrivateDirectoryEnabled && !sdcardDownload, icon = Icons.Outlined.LibraryMusic, ) { openDirectoryChooser(directory = Directory.AUDIO) } } } item { PreferenceItem( title = stringResource(id = R.string.custom_command_directory), description = customCommandDirectory.ifEmpty { stringResource(id = R.string.set_directory_desc) }, icon = Icons.Outlined.Folder, ) { showCustomCommandDirectoryDialog = true } } item { PreferenceSwitchWithDivider( title = stringResource(id = R.string.sdcard_directory), description = sdcardUri.ifEmpty { stringResource(id = R.string.set_directory_desc) }, isChecked = sdcardDownload, enabled = !isCustomCommandEnabled, isSwitchEnabled = !isCustomCommandEnabled, onChecked = { if (sdcardUri.isNotEmpty()) { sdcardDownload = !sdcardDownload PreferenceUtil.updateValue(SDCARD_DOWNLOAD, sdcardDownload) } else { openDirectoryChooser(Directory.SDCARD) } }, icon = Icons.Outlined.SdCard, onClick = { openDirectoryChooser(Directory.SDCARD) }, ) } item { PreferenceItem( title = stringResource(id = R.string.subdirectory), description = stringResource(id = R.string.subdirectory_desc), icon = Icons.Outlined.SnippetFolder, enabled = !isCustomCommandEnabled && !sdcardDownload, ) { showSubdirectoryDialog = true } } item { PreferenceSubtitle(text = stringResource(R.string.privacy)) } item { PreferenceSwitch( title = stringResource(id = R.string.private_directory), description = stringResource(R.string.private_directory_desc), icon = Icons.Outlined.TabUnselected, enabled = !showDirectoryAlert && !sdcardDownload && !isCustomCommandEnabled, isChecked = isPrivateDirectoryEnabled, onClick = { isPrivateDirectoryEnabled = !isPrivateDirectoryEnabled PreferenceUtil.updateValue(PRIVATE_DIRECTORY, isPrivateDirectoryEnabled) }, ) } item { PreferenceSubtitle(text = stringResource(R.string.advanced_settings)) } item { PreferenceItem( title = stringResource(R.string.output_template), description = stringResource(id = R.string.output_template_desc), icon = Icons.Outlined.FolderSpecial, enabled = !isCustomCommandEnabled && !sdcardDownload, onClick = { showOutputTemplateDialog = true }, ) } item { var restrictFilenames by RESTRICT_FILENAMES.booleanState PreferenceSwitch( title = stringResource(id = R.string.restrict_filenames), icon = Icons.Outlined.Spellcheck, description = stringResource(id = R.string.restrict_filenames_desc), isChecked = restrictFilenames, ) { restrictFilenames = !restrictFilenames RESTRICT_FILENAMES.updateBoolean(restrictFilenames) } } item { PreferenceItem( title = stringResource(R.string.clear_temp_files), description = stringResource(R.string.clear_temp_files_desc), icon = Icons.Outlined.FolderDelete, onClick = { showClearTempDialog = true }, ) } } } if (showClearTempDialog) { AlertDialog( onDismissRequest = { showClearTempDialog = false }, icon = { Icon(Icons.Outlined.FolderDelete, null) }, title = { Text(stringResource(id = R.string.clear_temp_files)) }, dismissButton = { DismissButton { showClearTempDialog = false } }, text = { Text( stringResource( R.string.clear_temp_files_info, getExternalTempDir().absolutePath, ), style = MaterialTheme.typography.bodyLarge, ) }, confirmButton = { ConfirmButton { showClearTempDialog = false scope.launch(Dispatchers.IO) { FileUtil.clearTempFiles(context.getConfigDirectory()) val count = FileUtil.run { clearTempFiles(getExternalTempDir()) + clearTempFiles(context.getSdcardTempDir(null)) + clearTempFiles(context.getInternalTempDir()) } withContext(Dispatchers.Main) { snackbarHostState.showSnackbar( context.getString(R.string.clear_temp_files_count).format(count) ) } } } }, ) } val outputTemplate by remember(showOutputTemplateDialog) { mutableStateOf(OUTPUT_TEMPLATE.getString()) } val customTemplate by remember(showOutputTemplateDialog) { mutableStateOf(CUSTOM_OUTPUT_TEMPLATE.getString()) } if (showOutputTemplateDialog) { OutputTemplateDialog( selectedTemplate = outputTemplate, customTemplate = customTemplate, onDismissRequest = { showOutputTemplateDialog = false }, onConfirm = { selected, custom -> OUTPUT_TEMPLATE.updateString(selected) CUSTOM_OUTPUT_TEMPLATE.updateString(custom) showOutputTemplateDialog = false }, ) } if (showCustomCommandDirectoryDialog) { AlertDialog( onDismissRequest = { showCustomCommandDirectoryDialog = false }, icon = { Icon(imageVector = Icons.Outlined.Folder, contentDescription = null) }, title = { Text( text = stringResource(id = R.string.custom_command_directory), textAlign = TextAlign.Center, ) }, confirmButton = { ConfirmButton { COMMAND_DIRECTORY.updateString(customCommandDirectory) showCustomCommandDirectoryDialog = false } }, text = { Column(modifier = Modifier.verticalScroll(rememberScrollState())) { Text( modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), text = stringResource(R.string.custom_command_directory_desc), style = MaterialTheme.typography.bodyLarge, ) OutlinedTextField( modifier = Modifier.padding(vertical = 8.dp), value = customCommandDirectory, onValueChange = { customCommandDirectory = it }, leadingIcon = { Text(text = "-P", fontFamily = FontFamily.Monospace) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), ) Row(modifier = Modifier.horizontalScroll(rememberScrollState())) { OutlinedButtonChip( modifier = Modifier.padding(end = 8.dp), label = stringResource(id = R.string.folder_picker), icon = Icons.Outlined.FolderOpen, ) { openDirectoryChooser(Directory.CUSTOM_COMMAND) } OutlinedButtonChip( label = stringResource(R.string.yt_dlp_docs), icon = Icons.AutoMirrored.Outlined.OpenInNew, ) { uriHandler.openUri(ytdlpFilesystemReference) } } } }, dismissButton = { DismissButton { showCustomCommandDirectoryDialog = false } }, ) } if (showSubdirectoryDialog) { DirectoryPreferenceDialog( onDismissRequest = { showSubdirectoryDialog = false }, isWebsiteSelected = SUBDIRECTORY_EXTRACTOR.getBoolean(), isPlaylistTitleSelected = SUBDIRECTORY_PLAYLIST_TITLE.getBoolean(), onConfirm = { isWebsiteSelected, isPlaylistTitleSelected -> SUBDIRECTORY_EXTRACTOR.updateBoolean(isWebsiteSelected) SUBDIRECTORY_PLAYLIST_TITLE.updateBoolean(isPlaylistTitleSelected) }, ) } } @Composable @Preview fun OutputTemplateDialog( selectedTemplate: String = DownloadUtil.OUTPUT_TEMPLATE_DEFAULT, customTemplate: String = DownloadUtil.OUTPUT_TEMPLATE_ID, onDismissRequest: () -> Unit = {}, onConfirm: (String, String) -> Unit = { s, s1 -> }, ) { var editingTemplate by remember { mutableStateOf(customTemplate) } var selectedItem by remember { mutableIntStateOf( when (selectedTemplate) { DownloadUtil.OUTPUT_TEMPLATE_DEFAULT -> 1 DownloadUtil.OUTPUT_TEMPLATE_ID -> 2 else -> 3 } ) } var error by remember { mutableIntStateOf(0) } SealDialog( onDismissRequest = onDismissRequest, confirmButton = { ConfirmButton(enabled = error == 0) { onConfirm( when (selectedItem) { 1 -> DownloadUtil.OUTPUT_TEMPLATE_DEFAULT 2 -> DownloadUtil.OUTPUT_TEMPLATE_ID else -> editingTemplate }, editingTemplate, ) } }, dismissButton = { DismissButton { onDismissRequest() } }, title = { Text(text = stringResource(id = R.string.output_template)) }, icon = { Icon(imageVector = Icons.Outlined.FolderSpecial, contentDescription = null) }, text = { Column { Text( text = stringResource(id = R.string.output_template_desc), modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 12.dp), style = MaterialTheme.typography.bodyLarge, ) Column(modifier = Modifier.verticalScroll(rememberScrollState())) { CompositionLocalProvider( LocalTextStyle provides LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace) ) { DialogSingleChoiceItem( text = DownloadUtil.OUTPUT_TEMPLATE_DEFAULT, selected = selectedItem == 1, ) { selectedItem = 1 } DialogSingleChoiceItem( text = DownloadUtil.OUTPUT_TEMPLATE_ID, selected = selectedItem == 2, ) { selectedItem = 2 } Row( modifier = Modifier.fillMaxWidth() .padding(horizontal = 12.dp) .padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start, ) { RadioButton( modifier = Modifier.clearAndSetSemantics {}, selected = selectedItem == 3, onClick = { selectedItem = 3 }, ) OutlinedTextField( value = editingTemplate, onValueChange = { error = if (!it.contains(DownloadUtil.BASENAME)) { 1 } else if (!it.endsWith(DownloadUtil.EXTENSION)) { 2 } else { 0 } editingTemplate = it }, isError = error != 0, supportingText = { Text( "Required: ${DownloadUtil.BASENAME}, ${DownloadUtil.EXTENSION}", fontFamily = FontFamily.Monospace, ) }, label = { Text(text = stringResource(id = R.string.custom)) }, ) } } } LinkButton( link = ytdlpOutputTemplateReference, modifier = Modifier.padding(horizontal = 16.dp), ) } }, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/format/DownloadFormatPreferences.kt ================================================ package com.junkfood.seal.ui.page.settings.format import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ArtTrack import androidx.compose.material.icons.outlined.ContentCut import androidx.compose.material.icons.outlined.Crop import androidx.compose.material.icons.outlined.HighQuality import androidx.compose.material.icons.outlined.Movie import androidx.compose.material.icons.outlined.MusicNote import androidx.compose.material.icons.outlined.Sort import androidx.compose.material.icons.outlined.SpatialAudioOff import androidx.compose.material.icons.outlined.Subtitles import androidx.compose.material.icons.outlined.Sync import androidx.compose.material.icons.outlined.VideoFile import androidx.compose.material.icons.outlined.VideoSettings import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import com.junkfood.seal.R import com.junkfood.seal.ui.common.booleanState import com.junkfood.seal.ui.common.intState import com.junkfood.seal.ui.component.BackButton import com.junkfood.seal.ui.component.ConfirmButton import com.junkfood.seal.ui.component.DismissButton import com.junkfood.seal.ui.component.PreferenceInfo import com.junkfood.seal.ui.component.PreferenceItem import com.junkfood.seal.ui.component.PreferenceSubtitle import com.junkfood.seal.ui.component.PreferenceSwitch import com.junkfood.seal.ui.component.PreferenceSwitchWithDivider import com.junkfood.seal.util.AUDIO_CONVERSION_FORMAT import com.junkfood.seal.util.AUDIO_CONVERT import com.junkfood.seal.util.CROP_ARTWORK import com.junkfood.seal.util.CUSTOM_COMMAND import com.junkfood.seal.util.DownloadUtil import com.junkfood.seal.util.DownloadUtil.toFormatSorter import com.junkfood.seal.util.EMBED_METADATA import com.junkfood.seal.util.EMBED_SUBTITLE import com.junkfood.seal.util.EXTRACT_AUDIO import com.junkfood.seal.util.FORMAT_SELECTION import com.junkfood.seal.util.FORMAT_SORTING import com.junkfood.seal.util.MERGE_MULTI_AUDIO_STREAM import com.junkfood.seal.util.MERGE_OUTPUT_MKV import com.junkfood.seal.util.PreferenceStrings import com.junkfood.seal.util.PreferenceUtil import com.junkfood.seal.util.PreferenceUtil.getBoolean import com.junkfood.seal.util.PreferenceUtil.getString import com.junkfood.seal.util.PreferenceUtil.updateBoolean import com.junkfood.seal.util.PreferenceUtil.updateInt import com.junkfood.seal.util.PreferenceUtil.updateString import com.junkfood.seal.util.SORTING_FIELDS import com.junkfood.seal.util.SUBTITLE import com.junkfood.seal.util.VIDEO_CLIP import com.junkfood.seal.util.VIDEO_FORMAT import com.junkfood.seal.util.VIDEO_QUALITY @OptIn(ExperimentalMaterial3Api::class) @Composable fun DownloadFormatPreferences(onNavigateBack: () -> Unit, navigateToSubtitlePage: () -> Unit) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( rememberTopAppBarState(), canScroll = { true }, ) var audioSwitch by remember { mutableStateOf(EXTRACT_AUDIO.getBoolean()) } var isArtworkCroppingEnabled by remember { mutableStateOf(CROP_ARTWORK.getBoolean()) } val downloadSubtitle by SUBTITLE.booleanState val embedSubtitle by EMBED_SUBTITLE.booleanState var remuxToMkv by MERGE_OUTPUT_MKV.booleanState var embedMetadata by EMBED_METADATA.booleanState var showAudioFormatDialog by remember { mutableStateOf(false) } var showAudioQualityDialog by remember { mutableStateOf(false) } var showAudioConvertDialog by remember { mutableStateOf(false) } var showVideoQualityDialog by remember { mutableStateOf(false) } var showVideoFormatDialog by remember { mutableStateOf(false) } var showFormatSorterDialog by remember { mutableStateOf(false) } var showVideoClipDialog by remember { mutableStateOf(false) } var videoFormat by VIDEO_FORMAT.intState var videoQuality by VIDEO_QUALITY.intState var convertFormat by AUDIO_CONVERSION_FORMAT.intState var sortingFields by remember(showFormatSorterDialog) { mutableStateOf(SORTING_FIELDS.getString()) } // val audioFormat by remember(showAudioFormatDialog) { // mutableStateOf(PreferenceStrings.getAudioFormatDesc()) } var convertAudio by AUDIO_CONVERT.booleanState var isFormatSortingEnabled by FORMAT_SORTING.booleanState // val audioQuality by remember(showAudioQualityDialog) { // mutableStateOf(PreferenceStrings.getAudioQualityDesc()) } var isVideoClipEnabled by VIDEO_CLIP.booleanState var isFormatSelectionEnabled by FORMAT_SELECTION.booleanState var mergeAudioStream by MERGE_MULTI_AUDIO_STREAM.booleanState var showMergeAudioDialog by remember { mutableStateOf(false) } Scaffold( modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { LargeTopAppBar( title = { Text(modifier = Modifier, text = stringResource(id = R.string.format)) }, navigationIcon = { BackButton { onNavigateBack() } }, scrollBehavior = scrollBehavior, ) }, content = { val isCustomCommandEnabled by remember { mutableStateOf(CUSTOM_COMMAND.getBoolean()) } LazyColumn(contentPadding = it) { if (isCustomCommandEnabled) item { PreferenceInfo( text = stringResource(id = R.string.custom_command_enabled_hint) ) } item { PreferenceSubtitle(text = stringResource(id = R.string.audio)) } item { PreferenceSwitch( title = stringResource(id = R.string.extract_audio), description = stringResource(id = R.string.extract_audio_summary), icon = Icons.Outlined.MusicNote, isChecked = audioSwitch, enabled = !isCustomCommandEnabled, onClick = { audioSwitch = !audioSwitch PreferenceUtil.updateValue(EXTRACT_AUDIO, audioSwitch) }, ) } // item { // PreferenceItem(title = stringResource(id = // R.string.audio_format_preference), // description = audioFormat, // icon = Icons.Outlined.AudioFile, // enabled = !isCustomCommandEnabled && // !isFormatSortingEnabled, // onClick = { showAudioFormatDialog = true }) // } // item { // PreferenceItem( // title = stringResource(id = R.string.audio_quality), // description = audioQuality, // icon = Icons.Outlined.HighQuality, // onClick = { showAudioQualityDialog = true }, // enabled = !isCustomCommandEnabled && // !isFormatSortingEnabled // ) // } item { PreferenceSwitchWithDivider( title = stringResource(R.string.convert_audio_format), description = PreferenceStrings.getAudioConvertDesc(convertFormat), icon = Icons.Outlined.Sync, enabled = audioSwitch && !isCustomCommandEnabled, onClick = { showAudioConvertDialog = true }, isChecked = convertAudio, onChecked = { convertAudio = !convertAudio AUDIO_CONVERT.updateBoolean(convertAudio) }, ) } item { PreferenceSwitch( title = stringResource(id = R.string.embed_metadata), description = stringResource(id = R.string.embed_metadata_desc), enabled = audioSwitch && !isCustomCommandEnabled, isChecked = embedMetadata, icon = Icons.Outlined.ArtTrack, onClick = { embedMetadata = !embedMetadata EMBED_METADATA.updateBoolean(embedMetadata) }, ) } item { PreferenceSwitch( title = stringResource(R.string.crop_artwork), description = stringResource(R.string.crop_artwork_desc), icon = Icons.Outlined.Crop, enabled = embedMetadata && audioSwitch && !isCustomCommandEnabled, isChecked = isArtworkCroppingEnabled, ) { isArtworkCroppingEnabled = !isArtworkCroppingEnabled PreferenceUtil.updateValue(CROP_ARTWORK, isArtworkCroppingEnabled) } } item { PreferenceSubtitle(text = stringResource(id = R.string.video)) } item { PreferenceItem( title = stringResource(R.string.video_format_preference), description = PreferenceStrings.getVideoFormatLabel(videoFormat), icon = Icons.Outlined.VideoFile, enabled = !audioSwitch && !isCustomCommandEnabled && !isFormatSortingEnabled, ) { showVideoFormatDialog = true } } item { PreferenceItem( title = stringResource(id = R.string.video_quality), description = PreferenceStrings.getVideoResolutionDesc(videoQuality), icon = Icons.Outlined.HighQuality, enabled = !audioSwitch && !isCustomCommandEnabled && !isFormatSortingEnabled, ) { showVideoQualityDialog = true } } /* item { var embedThumbnail by EMBED_THUMBNAIL.booleanState PreferenceSwitch( title = stringResource(id = R.string.embed_thumbnail), description = stringResource(id = R.string.embed_thumbnail_desc), icon = Icons.Outlined.Photo, isChecked = embedThumbnail, enabled = !isCustomCommandEnabled && !audioSwitch ) { embedThumbnail = !embedThumbnail EMBED_THUMBNAIL.updateBoolean(embedThumbnail) } }*/ item { PreferenceSwitch( title = stringResource(id = R.string.remux_container_mkv), description = stringResource(id = R.string.remux_container_mkv_desc), isChecked = (downloadSubtitle && embedSubtitle) || remuxToMkv, icon = Icons.Outlined.Movie, enabled = !(downloadSubtitle && embedSubtitle) && !isCustomCommandEnabled && !audioSwitch, onClick = { remuxToMkv = !remuxToMkv MERGE_OUTPUT_MKV.updateBoolean(remuxToMkv) }, ) } if (downloadSubtitle && embedSubtitle) { item { PreferenceInfo(text = stringResource(id = R.string.embed_subtitles_mkv_msg)) } } item { PreferenceSubtitle(text = stringResource(id = R.string.advanced_settings)) } item { PreferenceItem( title = stringResource(id = R.string.subtitle), icon = Icons.Outlined.Subtitles, enabled = !isCustomCommandEnabled, description = stringResource(id = R.string.subtitle_desc), ) { navigateToSubtitlePage() } } item { PreferenceSwitchWithDivider( title = stringResource(id = R.string.format_sorting), icon = Icons.Outlined.Sort, description = stringResource(id = R.string.format_sorting_desc), enabled = !isCustomCommandEnabled, isChecked = isFormatSortingEnabled, onChecked = { isFormatSortingEnabled = !isFormatSortingEnabled FORMAT_SORTING.updateBoolean(isFormatSortingEnabled) }, onClick = { showFormatSorterDialog = true }, ) } item { PreferenceSwitch( title = stringResource(id = R.string.format_selection), icon = Icons.Outlined.VideoSettings, enabled = !isCustomCommandEnabled, description = stringResource(id = R.string.format_selection_desc), isChecked = isFormatSelectionEnabled, ) { isFormatSelectionEnabled = !isFormatSelectionEnabled PreferenceUtil.updateValue(FORMAT_SELECTION, isFormatSelectionEnabled) } } item { PreferenceSwitch( title = stringResource(id = R.string.clip_video), description = stringResource(id = R.string.clip_video_desc), icon = Icons.Outlined.ContentCut, isChecked = isVideoClipEnabled, enabled = !isCustomCommandEnabled && isFormatSelectionEnabled, ) { if (!isVideoClipEnabled) showVideoClipDialog = true else { isVideoClipEnabled = false VIDEO_CLIP.updateBoolean(false) } } } item { PreferenceSwitch( title = stringResource(id = R.string.merge_audiostream), description = stringResource(id = R.string.merge_audiostream_desc), isChecked = mergeAudioStream, icon = Icons.Outlined.SpatialAudioOff, onClick = { if (mergeAudioStream) { mergeAudioStream = false MERGE_MULTI_AUDIO_STREAM.updateBoolean(false) } else { showMergeAudioDialog = true } }, enabled = !isCustomCommandEnabled && isFormatSelectionEnabled, ) } } }, ) if (showAudioFormatDialog) { AudioFormatDialog { showAudioFormatDialog = false } } if (showAudioQualityDialog) { AudioQualityDialog { showAudioQualityDialog = false } } if (showAudioConvertDialog) { AudioConversionDialog( onDismissRequest = { showAudioConvertDialog = false }, audioFormat = convertFormat, onConfirm = { convertFormat = it AUDIO_CONVERSION_FORMAT.updateInt(it) }, ) } if (showVideoQualityDialog) { VideoQualityDialog( videoQuality = videoQuality, onDismissRequest = { showVideoQualityDialog = false }, ) { videoQuality = it VIDEO_QUALITY.updateInt(it) } } if (showVideoFormatDialog) { VideoFormatDialog( videoFormatPreference = videoFormat, onDismissRequest = { showVideoFormatDialog = false }, ) { PreferenceUtil.encodeInt(VIDEO_FORMAT, it) videoFormat = it } } if (showFormatSorterDialog) { FormatSortingDialog( fields = sortingFields, onImport = { sortingFields = DownloadUtil.DownloadPreferences.createFromPreferences().toFormatSorter() }, onDismissRequest = { showFormatSorterDialog = false }, showSwitch = false, onConfirm = { sortingFields = it SORTING_FIELDS.updateString(sortingFields) }, ) } if (showVideoClipDialog) { AlertDialog( onDismissRequest = { showVideoClipDialog = false }, icon = { Icon(Icons.Outlined.ContentCut, null) }, confirmButton = { ConfirmButton { isVideoClipEnabled = true VIDEO_CLIP.updateBoolean(true) showVideoClipDialog = false } }, dismissButton = { DismissButton { showVideoClipDialog = false } }, text = { Text(stringResource(id = R.string.clip_video_dialog_msg)) }, title = { Text( stringResource(id = R.string.enable_experimental_feature), textAlign = TextAlign.Center, ) }, ) } if (showMergeAudioDialog) { AlertDialog( onDismissRequest = { showMergeAudioDialog = false }, icon = { Icon(Icons.Outlined.SpatialAudioOff, null) }, confirmButton = { ConfirmButton { mergeAudioStream = true MERGE_MULTI_AUDIO_STREAM.updateBoolean(true) showMergeAudioDialog = false } }, dismissButton = { DismissButton { showMergeAudioDialog = false } }, text = { Text(stringResource(id = R.string.merge_audiostream_desc)) }, title = { Text( stringResource(id = R.string.enable_experimental_feature), textAlign = TextAlign.Center, ) }, ) } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/format/FormatSettingDialogs.kt ================================================ package com.junkfood.seal.ui.page.settings.format import androidx.compose.animation.AnimatedContent import androidx.compose.animation.SizeTransform import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.OpenInNew import androidx.compose.material.icons.automirrored.outlined.Sort import androidx.compose.material.icons.outlined.AudioFile import androidx.compose.material.icons.outlined.HighQuality import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.SettingsSuggest import androidx.compose.material.icons.outlined.Sync import androidx.compose.material.icons.outlined.VideoFile import androidx.compose.material.icons.outlined.VideoSettings import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ElevatedAssistChip import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.junkfood.seal.R import com.junkfood.seal.ui.common.booleanState import com.junkfood.seal.ui.common.intState import com.junkfood.seal.ui.common.motion.materialSharedAxisX import com.junkfood.seal.ui.common.stringState import com.junkfood.seal.ui.component.ConfirmButton import com.junkfood.seal.ui.component.DialogSingleChoiceItem import com.junkfood.seal.ui.component.DialogSingleChoiceItemVariant import com.junkfood.seal.ui.component.DialogSubtitle import com.junkfood.seal.ui.component.DialogSwitchItem import com.junkfood.seal.ui.component.DismissButton import com.junkfood.seal.ui.component.OutlinedButtonChip import com.junkfood.seal.ui.component.PreferenceSubtitle import com.junkfood.seal.ui.component.SealDialog import com.junkfood.seal.ui.component.SealTextField import com.junkfood.seal.ui.page.downloadv2.configure.PreferencesMock import com.junkfood.seal.util.AUDIO_CONVERSION_FORMAT import com.junkfood.seal.util.AUDIO_CONVERT import com.junkfood.seal.util.AUDIO_FORMAT import com.junkfood.seal.util.AUDIO_QUALITY import com.junkfood.seal.util.CONVERT_M4A import com.junkfood.seal.util.CONVERT_MP3 import com.junkfood.seal.util.CONVERT_SUBTITLE import com.junkfood.seal.util.CONVERT_VTT import com.junkfood.seal.util.DEFAULT import com.junkfood.seal.util.DownloadUtil import com.junkfood.seal.util.FORMAT_COMPATIBILITY import com.junkfood.seal.util.FORMAT_QUALITY import com.junkfood.seal.util.M4A import com.junkfood.seal.util.NOT_CONVERT import com.junkfood.seal.util.NOT_SPECIFIED import com.junkfood.seal.util.OPUS import com.junkfood.seal.util.PreferenceStrings import com.junkfood.seal.util.PreferenceUtil import com.junkfood.seal.util.PreferenceUtil.updateBoolean import com.junkfood.seal.util.PreferenceUtil.updateInt import com.junkfood.seal.util.PreferenceUtil.updateString import com.junkfood.seal.util.RES_HIGHEST import com.junkfood.seal.util.RES_LOWEST import com.junkfood.seal.util.SUBTITLE_LANGUAGE import com.junkfood.seal.util.ULTRA_LOW import com.junkfood.seal.util.getStringDefault @OptIn(ExperimentalMaterial3Api::class) @Composable fun VideoResolutionSelectField( modifier: Modifier = Modifier, videoResolution: Int, onSelect: (Int) -> Unit, ) { var expanded by remember { mutableStateOf(false) } val videoResolutionText = PreferenceStrings.getVideoResolutionDesc(videoResolution) ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = !expanded }) { SealTextField( modifier = modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), value = videoResolutionText, onValueChange = {}, readOnly = true, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), // label = { Text(stringResource(id = R.string.video_resolution)) } ) ExposedDropdownMenu( modifier = Modifier, scrollState = rememberScrollState(), expanded = expanded, onDismissRequest = { expanded = false }, ) { for (i in RES_HIGHEST..RES_LOWEST) DropdownMenuItem( text = { Text(PreferenceStrings.getVideoResolutionDesc(i)) }, onClick = { onSelect(i) expanded = false }, ) } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun VideoFormatPreferenceSelectField( modifier: Modifier = Modifier, videoFormatPreference: Int, onSelect: (Int) -> Unit, ) { var expanded by remember { mutableStateOf(false) } val videoFormatText = PreferenceStrings.getVideoFormatLabel(videoFormatPreference) ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = !expanded }) { OutlinedTextField( modifier = modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), value = videoFormatText, onValueChange = {}, readOnly = true, leadingIcon = { Icon(Icons.Outlined.VideoFile, null) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), label = { Text(stringResource(id = R.string.video_format_preference)) }, ) ExposedDropdownMenu( modifier = Modifier.verticalScroll(rememberScrollState()), expanded = expanded, onDismissRequest = { expanded = false }, ) { for (i in listOf(FORMAT_COMPATIBILITY, FORMAT_QUALITY)) DropdownMenuItem( text = { Text(PreferenceStrings.getVideoFormatLabel(i)) }, onClick = { onSelect(i) expanded = false }, ) } } } @Composable fun VideoQuickSettingsDialog( videoResolution: Int, videoFormatPreference: Int, onResolutionSelect: (Int) -> Unit, onFormatSelect: (Int) -> Unit, onSave: () -> Unit = {}, onDismissRequest: () -> Unit = {}, ) { SealDialog( onDismissRequest = onDismissRequest, icon = { Icon(Icons.Outlined.VideoFile, null) }, title = { Text(text = stringResource(id = R.string.edit_preset)) }, dismissButton = { OutlinedButton(onClick = onDismissRequest) { Text(stringResource(R.string.cancel)) } }, confirmButton = { Button( onClick = { onSave() onDismissRequest() } ) { Text(text = stringResource(R.string.save)) } }, text = { Column { LazyColumn() { item { DialogSubtitle(text = stringResource(R.string.video_format_preference)) } for (i in listOf(FORMAT_COMPATIBILITY, FORMAT_QUALITY)) { item { DialogSingleChoiceItemVariant( modifier = Modifier, title = PreferenceStrings.getVideoFormatLabel(i), desc = PreferenceStrings.getVideoFormatDescComp(i), selected = videoFormatPreference == i, ) { onFormatSelect(i) } } } item { DialogSubtitle(text = stringResource(R.string.video_resolution)) } item { VideoResolutionSelectField( modifier = Modifier.padding(horizontal = 12.dp), videoResolution = videoResolution, onSelect = onResolutionSelect, ) } } } }, ) } @Preview @Composable private fun VideoPreview() { VideoQuickSettingsDialog( videoResolution = RES_HIGHEST, videoFormatPreference = FORMAT_QUALITY, onResolutionSelect = {}, onFormatSelect = {}, ) {} } @Preview @Composable private fun AudioPreview() { var b by remember { mutableStateOf(false) } var b1 by remember { mutableStateOf(false) } var i1 by remember { mutableIntStateOf(1) } var i2 by remember { mutableIntStateOf(0) } var i3 by remember { mutableIntStateOf(NOT_SPECIFIED) } AudioQuickSettingsDialog( preferences = PreferencesMock, convertAudio = b, useCustomAudioPreset = b1, onCustomPresetToggle = { b1 = it }, preferredFormat = i1, conversionFormat = i2, onConvertToggled = { b = it }, onPreferredSelect = { i1 = it }, onConversionSelect = { i2 = it }, audioQuality = i3, onQualitySelect = { i3 = it }, onSave = {}, ) } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun AudioFormatSelectField( modifier: Modifier = Modifier, convertAudio: Boolean, preferredFormat: Int, conversionFormat: Int, onConvertToggled: (Boolean) -> Unit, onPreferredSelect: (Int) -> Unit, onConversionSelect: (Int) -> Unit, ) { var expanded by remember { mutableStateOf(false) } val preferredFormatText = PreferenceStrings.getAudioFormatDesc(preferredFormat) val conversionFormatText = PreferenceStrings.getAudioConvertDesc(conversionFormat) val userSelectionText = if (convertAudio) conversionFormatText else preferredFormatText PreferenceSubtitle(text = stringResource(R.string.audio_format)) ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = !expanded }) { SealTextField( modifier = modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), value = userSelectionText, onValueChange = {}, readOnly = true, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), ) ExposedDropdownMenu( modifier = Modifier, scrollState = rememberScrollState(), expanded = expanded, onDismissRequest = { expanded = false }, ) { for (i in OPUS..M4A) { DropdownMenuItem( text = { Text(PreferenceStrings.getAudioFormatDesc(i)) }, onClick = { onPreferredSelect(i) onConvertToggled(false) expanded = false }, ) } for (i in CONVERT_MP3..CONVERT_M4A) { DropdownMenuItem( text = { Text(PreferenceStrings.getAudioConvertDesc(i)) }, onClick = { onConversionSelect(i) onConvertToggled(true) expanded = false }, ) } } } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun AudioQualitySelectField( modifier: Modifier = Modifier, enabled: Boolean = true, audioQuality: Int, onSelect: (Int) -> Unit, ) { var expanded by remember { mutableStateOf(false) } PreferenceSubtitle(text = stringResource(R.string.audio_quality)) ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = !expanded }) { SealTextField( enabled = enabled, modifier = modifier .fillMaxWidth() .menuAnchor(MenuAnchorType.PrimaryNotEditable, enabled = enabled), value = if (!enabled) stringResource(R.string.unavailable) else PreferenceStrings.getAudioQualityDesc(audioQuality), onValueChange = {}, readOnly = true, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), ) ExposedDropdownMenu( modifier = Modifier, scrollState = rememberScrollState(), expanded = expanded, onDismissRequest = { expanded = false }, ) { for (i in NOT_SPECIFIED..ULTRA_LOW) { DropdownMenuItem( text = { Text(PreferenceStrings.getAudioQualityDesc(i)) }, onClick = { onSelect(i) expanded = false }, ) } } } } @Composable fun AudioQuickSettingsDialog( modifier: Modifier = Modifier, preferences: DownloadUtil.DownloadPreferences, onDismissRequest: () -> Unit = {}, useCustomAudioPreset: Boolean, onCustomPresetToggle: (Boolean) -> Unit, convertAudio: Boolean, preferredFormat: Int, conversionFormat: Int, onConvertToggled: (Boolean) -> Unit, onPreferredSelect: (Int) -> Unit, onConversionSelect: (Int) -> Unit, audioQuality: Int, onQualitySelect: (Int) -> Unit, onSave: () -> Unit, ) { var editingPreset by remember { mutableStateOf(false) } SealDialog( modifier = modifier, onDismissRequest = onDismissRequest, icon = { Icon(Icons.Outlined.AudioFile, null) }, title = { Text(stringResource(R.string.edit_preset)) }, text = { AnimatedContent( editingPreset, transitionSpec = { materialSharedAxisX(initialOffsetX = { it / 5 }, targetOffsetX = { -it / 5 }) .using(SizeTransform()) }, label = "", ) { if (!it) { Column(modifier = Modifier.verticalScroll(rememberScrollState())) { DialogSubtitle(text = stringResource(R.string.presets)) DialogSingleChoiceItemVariant( title = stringResource(R.string.best_quality), selected = !useCustomAudioPreset, desc = stringResource(R.string.best_quality_desc), onClick = { onCustomPresetToggle(false) }, ) DialogSingleChoiceItemVariant( title = stringResource(R.string.custom), selected = useCustomAudioPreset, onClick = { onCustomPresetToggle(true) }, desc = PreferenceStrings.getAudioPresetText( preferences.copy(useCustomAudioPreset = true) ), action = { if (useCustomAudioPreset) { Spacer(Modifier.width(8.dp)) VerticalDivider(Modifier.height(32.dp)) IconButton(onClick = { editingPreset = true }) { Icon( imageVector = Icons.Outlined.Settings, contentDescription = stringResource(R.string.edit), ) } } }, ) } } else { Column( modifier = Modifier.verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp) ) { AudioFormatSelectField( convertAudio = convertAudio, preferredFormat = preferredFormat, conversionFormat = conversionFormat, onConvertToggled = onConvertToggled, onPreferredSelect = onPreferredSelect, onConversionSelect = onConversionSelect, ) AudioQualitySelectField( audioQuality = audioQuality, enabled = !convertAudio, onSelect = onQualitySelect, ) } } } }, dismissButton = { OutlinedButton(onClick = onDismissRequest) { Text(stringResource(R.string.cancel)) } }, confirmButton = { Button( onClick = { onSave() onDismissRequest() } ) { Text(stringResource(R.string.save)) } }, ) } @Composable fun AudioConversionDialog( onDismissRequest: () -> Unit, audioFormat: Int, onConfirm: (Int) -> Unit = {}, ) { var audioFormat by remember { mutableIntStateOf(audioFormat) } SealDialog( onDismissRequest = onDismissRequest, dismissButton = { TextButton(onClick = onDismissRequest) { Text(stringResource(R.string.dismiss)) } }, icon = { Icon(Icons.Outlined.Sync, null) }, title = { Text(stringResource(R.string.convert_audio_format)) }, confirmButton = { TextButton( onClick = { AUDIO_CONVERSION_FORMAT.updateInt(audioFormat) onConfirm(audioFormat) onDismissRequest() } ) { Text(text = stringResource(R.string.confirm)) } }, text = { Column(modifier = Modifier.verticalScroll(rememberScrollState())) { Text( modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp).padding(horizontal = 24.dp), text = stringResource(R.string.convert_audio_format_desc), style = MaterialTheme.typography.bodyLarge, ) for (i in CONVERT_MP3..CONVERT_M4A) DialogSingleChoiceItem( modifier = Modifier, text = PreferenceStrings.getAudioConvertDesc(i), selected = audioFormat == i, ) { audioFormat = i } } }, ) } @Composable fun AudioConversionQuickSettingsDialog(onDismissRequest: () -> Unit, onConfirm: () -> Unit = {}) { var audioFormat by remember { mutableIntStateOf(PreferenceUtil.getAudioConvertFormat()) } var convertAudio by AUDIO_CONVERT.booleanState SealDialog( onDismissRequest = onDismissRequest, dismissButton = { DismissButton { onDismissRequest() } }, icon = { Icon(Icons.Outlined.Sync, null) }, title = { Text(stringResource(R.string.convert_audio_format)) }, confirmButton = { ConfirmButton { AUDIO_CONVERT.updateBoolean(convertAudio) AUDIO_CONVERSION_FORMAT.updateInt(audioFormat) onConfirm() onDismissRequest() } }, text = { Column(modifier = Modifier.verticalScroll(rememberScrollState())) { Text( modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp).padding(horizontal = 24.dp), text = stringResource(R.string.convert_audio_format_desc), style = MaterialTheme.typography.bodyLarge, ) DialogSingleChoiceItem( text = stringResource(id = R.string.not_convert), selected = !convertAudio, ) { convertAudio = false } for (i in CONVERT_MP3..CONVERT_M4A) DialogSingleChoiceItem( modifier = Modifier, text = PreferenceStrings.getAudioConvertDesc(i), selected = audioFormat == i && convertAudio, ) { audioFormat = i convertAudio = true } } }, ) } @Composable @Preview fun VideoFormatDialog( videoFormatPreference: Int = FORMAT_COMPATIBILITY, onDismissRequest: () -> Unit = {}, onConfirm: (Int) -> Unit = {}, ) { var preference by remember { mutableIntStateOf(videoFormatPreference) } SealDialog( onDismissRequest = onDismissRequest, dismissButton = { TextButton(onClick = onDismissRequest) { Text(stringResource(R.string.dismiss)) } }, icon = { Icon(Icons.Outlined.VideoFile, null) }, title = { Text(stringResource(R.string.video_format_preference)) }, confirmButton = { TextButton( onClick = { onConfirm(preference) onDismissRequest() } ) { Text(text = stringResource(R.string.confirm)) } }, text = { Column { androidx.compose.material3.HorizontalDivider() LazyColumn(modifier = Modifier, contentPadding = PaddingValues(vertical = 8.dp)) { for (i in listOf(FORMAT_COMPATIBILITY, FORMAT_QUALITY)) item { DialogSingleChoiceItemVariant( modifier = Modifier, title = PreferenceStrings.getVideoFormatLabel(i), desc = PreferenceStrings.getVideoFormatDescComp(i), selected = preference == i, ) { preference = i } } } androidx.compose.material3.HorizontalDivider() } }, ) } @Composable fun AudioFormatDialog(onDismissRequest: () -> Unit) { var audioFormat by AUDIO_FORMAT.intState SealDialog( onDismissRequest = onDismissRequest, dismissButton = { TextButton(onClick = onDismissRequest) { Text(stringResource(R.string.dismiss)) } }, icon = { Icon(Icons.Outlined.AudioFile, null) }, title = { Text(stringResource(R.string.audio_format_preference)) }, confirmButton = { ConfirmButton { AUDIO_FORMAT.updateInt(audioFormat) onDismissRequest() } }, text = { Column(modifier = Modifier.verticalScroll(rememberScrollState())) { Text( modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp).padding(horizontal = 24.dp), text = stringResource(R.string.preferred_format_desc), style = MaterialTheme.typography.bodyLarge, ) for (i in DEFAULT..M4A) DialogSingleChoiceItem( modifier = Modifier, text = PreferenceStrings.getAudioFormatDesc(i), selected = audioFormat == i, ) { audioFormat = i } } }, ) } @Composable fun AudioQualityDialog(onDismissRequest: () -> Unit) { var audioQuality by AUDIO_QUALITY.intState SealDialog( onDismissRequest = onDismissRequest, dismissButton = { DismissButton { onDismissRequest() } }, icon = { Icon(Icons.Outlined.HighQuality, null) }, title = { Text(stringResource(R.string.audio_quality)) }, confirmButton = { ConfirmButton { AUDIO_QUALITY.updateInt(audioQuality) onDismissRequest() } }, text = { Column(modifier = Modifier.verticalScroll(rememberScrollState())) { Text( modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp).padding(horizontal = 24.dp), text = stringResource(R.string.audio_quality_desc), style = MaterialTheme.typography.bodyLarge, ) for (i in NOT_SPECIFIED..ULTRA_LOW) DialogSingleChoiceItem( modifier = Modifier, text = PreferenceStrings.getAudioQualityDesc(i), selected = audioQuality == i, ) { audioQuality = i } } }, ) } @Composable fun FormatSortingDialog( fields: String, showSwitch: Boolean = false, toggleableValue: Boolean = false, onSwitchChecked: (Boolean) -> Unit = {}, onImport: () -> Unit = {}, onDismissRequest: () -> Unit = {}, onConfirm: (String) -> Unit = {}, ) { var sortingFields by remember(fields) { mutableStateOf(fields) } SealDialog( onDismissRequest = onDismissRequest, dismissButton = { DismissButton { onDismissRequest() } }, icon = { Icon(Icons.AutoMirrored.Outlined.Sort, null) }, title = { Text(stringResource(R.string.format_sorting)) }, confirmButton = { ConfirmButton(text = stringResource(id = R.string.save)) { onConfirm(sortingFields) onDismissRequest() } }, text = { Column(modifier = Modifier.verticalScroll(rememberScrollState())) { Text( modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp).padding(bottom = 12.dp), text = stringResource(R.string.format_sorting_desc), style = MaterialTheme.typography.bodyLarge, ) OutlinedTextField( modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), value = sortingFields, onValueChange = { sortingFields = it }, leadingIcon = { Text(text = "-S", fontFamily = FontFamily.Monospace) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), ) val uriHandler = LocalUriHandler.current Row( modifier = Modifier.padding(horizontal = 16.dp) .horizontalScroll(rememberScrollState()) .padding(horizontal = 8.dp) ) { OutlinedButtonChip( modifier = Modifier.padding(end = 8.dp), label = stringResource(id = R.string.import_from_preferences), icon = Icons.Outlined.SettingsSuggest, ) { onImport() } OutlinedButtonChip( label = stringResource(R.string.yt_dlp_docs), icon = Icons.AutoMirrored.Outlined.OpenInNew, ) { uriHandler.openUri(sortingFormats) } } if (showSwitch) { Spacer(modifier = Modifier.height(12.dp)) androidx.compose.material3.HorizontalDivider( modifier = Modifier.padding(horizontal = 24.dp) ) DialogSwitchItem( text = stringResource(id = R.string.use_format_sorting), value = toggleableValue, onValueChange = onSwitchChecked, ) } } }, ) } @Preview @Composable private fun FormatSortingDialogPreview() { var value by remember { mutableStateOf(false) } FormatSortingDialog( fields = "", showSwitch = true, toggleableValue = value, onSwitchChecked = { value = it }, ) } @Composable fun VideoQualityDialog( videoQuality: Int = 0, onDismissRequest: () -> Unit = {}, onConfirm: (Int) -> Unit = {}, ) { var videoResolution by remember { mutableIntStateOf(videoQuality) } SealDialog( onDismissRequest = onDismissRequest, dismissButton = { TextButton(onClick = onDismissRequest) { Text(stringResource(R.string.dismiss)) } }, icon = { Icon(Icons.Outlined.HighQuality, null) }, title = { Text(stringResource(R.string.video_quality)) }, confirmButton = { TextButton( onClick = { onConfirm(videoResolution) onDismissRequest() } ) { Text(text = stringResource(R.string.confirm)) } }, text = { Column() { Text( modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp).padding(horizontal = 24.dp), text = stringResource(R.string.video_quality_desc), style = MaterialTheme.typography.bodyLarge, ) LazyColumn() { // item { videoResolutionSelectField() } for (i in 0..7) { item { DialogSingleChoiceItem( text = PreferenceStrings.getVideoResolutionDesc(i), selected = videoResolution == i, ) { videoResolution = i } } } } } }, ) } private const val subtitleOptions = "https://github.com/yt-dlp/yt-dlp#subtitle-options" private const val sortingFormats = "https://github.com/yt-dlp/yt-dlp#sorting-formats" @Composable fun SubtitleLanguageDialog(onDismissRequest: () -> Unit) { var languages by SUBTITLE_LANGUAGE.stringState SubtitleLanguageDialogImpl( onDismissRequest = onDismissRequest, initialLanguages = languages, onReset = { SUBTITLE_LANGUAGE.let { languages = it.getStringDefault() it.updateString(languages) } }, onConfirm = { SUBTITLE_LANGUAGE.updateString(it) }, ) } @Composable @Preview private fun SubtitleLanguageDialogImpl( onDismissRequest: () -> Unit = {}, initialLanguages: String = "en.*,.*-orig", onReset: () -> Unit = {}, onConfirm: (String) -> Unit = {}, ) { var languages by remember(initialLanguages) { mutableStateOf(initialLanguages) } val uriHandler = LocalUriHandler.current SealDialog( onDismissRequest = onDismissRequest, title = { Text(stringResource(id = R.string.subtitle_language)) }, icon = { Icon(Icons.Outlined.Language, null) }, text = { Column() { Text( text = stringResource(id = R.string.subtitle_language_desc), modifier = Modifier.padding(horizontal = 24.dp), ) Spacer(modifier = Modifier.height(16.dp)) ProvideTextStyle( value = LocalTextStyle.current.merge(fontFamily = FontFamily.Monospace) ) { OutlinedTextField( modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), value = languages, onValueChange = { languages = it }, label = { Text(stringResource(id = R.string.subtitle_language)) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), ) } Spacer(modifier = Modifier.height(8.dp)) Row( modifier = Modifier.padding(horizontal = 16.dp) .horizontalScroll(rememberScrollState()) .padding(horizontal = 8.dp) ) { OutlinedButtonChip( modifier = Modifier.padding(end = 8.dp), label = stringResource(id = R.string.reset), icon = Icons.Outlined.Sync, ) { onReset() } OutlinedButtonChip( label = stringResource(R.string.yt_dlp_docs), icon = Icons.AutoMirrored.Outlined.OpenInNew, ) { uriHandler.openUri(sortingFormats) } } } }, confirmButton = { ConfirmButton() { onConfirm(languages) onDismissRequest() } }, dismissButton = { DismissButton() { onDismissRequest() } }, ) } @Composable fun SubtitleConversionDialog(onDismissRequest: () -> Unit) { var currentFormat by CONVERT_SUBTITLE.intState SealDialog( onDismissRequest = onDismissRequest, confirmButton = { ConfirmButton { CONVERT_SUBTITLE.updateInt(currentFormat) onDismissRequest() } }, dismissButton = { DismissButton { onDismissRequest() } }, title = { Text(text = stringResource(id = R.string.convert_subtitle)) }, icon = { Icon(imageVector = Icons.Outlined.Sync, contentDescription = null) }, text = { LazyColumn { item { Text( text = stringResource(id = R.string.convert_subtitle_desc), modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 12.dp), style = MaterialTheme.typography.bodyLarge, ) } for (format in NOT_CONVERT..CONVERT_VTT) { item { DialogSingleChoiceItem( text = PreferenceStrings.getSubtitleConversionFormat(format), selected = currentFormat == format, ) { currentFormat = format } } } } }, ) } @Composable @Preview fun VideoQualityPreferenceChip( modifier: Modifier = Modifier, videoQualityPreference: Int = FORMAT_COMPATIBILITY, enabled: Boolean = true, onClick: () -> Unit = {}, ) { ElevatedAssistChip( modifier = modifier, onClick = onClick, label = { Text(text = PreferenceStrings.getVideoFormatLabel(videoQualityPreference)) }, leadingIcon = { Icon( imageVector = Icons.Outlined.VideoSettings, contentDescription = null, modifier = Modifier.size(AssistChipDefaults.IconSize), ) }, ) } @Composable @Preview fun VideoResolutionChip( modifier: Modifier = Modifier, videoResolution: Int = 0, enabled: Boolean = true, onClick: () -> Unit = {}, ) { ElevatedAssistChip( modifier = modifier, onClick = onClick, label = { Text(text = PreferenceStrings.getVideoResolutionDesc(videoResolution)) }, leadingIcon = { Icon( imageVector = Icons.Outlined.VideoSettings, contentDescription = null, modifier = Modifier.size(AssistChipDefaults.IconSize), ) }, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/format/SubtitlePreference.kt ================================================ package com.junkfood.seal.ui.page.settings.format import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ClosedCaption import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.Save import androidx.compose.material.icons.outlined.Subtitles import androidx.compose.material.icons.outlined.Sync import androidx.compose.material.icons.outlined.Translate import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import com.junkfood.seal.R import com.junkfood.seal.ui.common.booleanState import com.junkfood.seal.ui.component.BackButton import com.junkfood.seal.ui.component.ConfirmButton import com.junkfood.seal.ui.component.DismissButton import com.junkfood.seal.ui.component.PreferenceInfo import com.junkfood.seal.ui.component.PreferenceItem import com.junkfood.seal.ui.component.PreferenceSwitch import com.junkfood.seal.ui.component.PreferenceSwitchWithContainer import com.junkfood.seal.util.AUTO_SUBTITLE import com.junkfood.seal.util.AUTO_TRANSLATED_SUBTITLES import com.junkfood.seal.util.EMBED_SUBTITLE import com.junkfood.seal.util.EXTRACT_AUDIO import com.junkfood.seal.util.KEEP_SUBTITLE_FILES import com.junkfood.seal.util.PreferenceStrings import com.junkfood.seal.util.PreferenceUtil.getString import com.junkfood.seal.util.PreferenceUtil.updateBoolean import com.junkfood.seal.util.SPONSORBLOCK import com.junkfood.seal.util.SUBTITLE import com.junkfood.seal.util.SUBTITLE_LANGUAGE @OptIn(ExperimentalMaterial3Api::class) @Composable fun SubtitlePreference(onNavigateBack: () -> Unit) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( rememberTopAppBarState(), canScroll = { true }, ) var downloadSubtitle by SUBTITLE.booleanState val sponsorBlock by SPONSORBLOCK.booleanState // var keepSubtitleFile by KEEP_SUBTITLE_FILES.booleanState var embedSubtitle by EMBED_SUBTITLE.booleanState var autoSubtitle by AUTO_SUBTITLE.booleanState var autoTranslatedSubtitle by AUTO_TRANSLATED_SUBTITLES.booleanState var showLanguageDialog by remember { mutableStateOf(false) } var showConversionDialog by remember { mutableStateOf(false) } var showEmbedSubtitleDialog by remember { mutableStateOf(false) } var showAutoTranslateDialog by remember { mutableStateOf(false) } val subtitleFormatText by remember(showConversionDialog) { mutableStateOf(PreferenceStrings.getSubtitleConversionFormat()) } val subtitleLang by remember(showLanguageDialog) { mutableStateOf(SUBTITLE_LANGUAGE.getString()) } val sponsorBlockText = stringResource(id = R.string.subtitle_sponsorblock) val embedSubtitleText = stringResource(R.string.embed_subtitles_mkv_msg) val hint by remember(sponsorBlock, embedSubtitle) { derivedStateOf { StringBuilder() .apply { if (sponsorBlock) append(sponsorBlockText) if (isNotEmpty()) append("\n\n") if (embedSubtitle) append(embedSubtitleText) } .toString() } } val downloadAudio by EXTRACT_AUDIO.booleanState Scaffold( modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { LargeTopAppBar( title = { Text(modifier = Modifier, text = stringResource(id = R.string.subtitle)) }, navigationIcon = { BackButton { onNavigateBack() } }, scrollBehavior = scrollBehavior, ) }, content = { LazyColumn(modifier = Modifier, contentPadding = it) { item { PreferenceSwitchWithContainer( title = stringResource(id = R.string.download_subtitles), isChecked = downloadSubtitle, onClick = { downloadSubtitle = !downloadSubtitle SUBTITLE.updateBoolean(downloadSubtitle) }, icon = null, ) } item { PreferenceItem( title = stringResource(id = R.string.subtitle_language), icon = Icons.Outlined.Language, description = subtitleLang, onClick = { showLanguageDialog = true }, ) } item { PreferenceItem( title = stringResource(id = R.string.convert_subtitle), description = subtitleFormatText, icon = Icons.Outlined.Sync, ) { showConversionDialog = true } } item { PreferenceSwitch( title = stringResource(id = R.string.auto_subtitle), icon = Icons.Outlined.ClosedCaption, description = stringResource(id = R.string.auto_subtitle_desc), isChecked = autoSubtitle, onClick = { autoSubtitle = !autoSubtitle AUTO_SUBTITLE.updateBoolean(autoSubtitle) }, ) } item { PreferenceSwitch( title = stringResource(id = R.string.auto_translated_subtitles), icon = Icons.Outlined.Translate, isChecked = autoTranslatedSubtitle, enabled = autoSubtitle, ) { if (!autoTranslatedSubtitle) { showAutoTranslateDialog = true } else { autoTranslatedSubtitle = false AUTO_TRANSLATED_SUBTITLES.updateBoolean(false) } } } item { androidx.compose.material3.HorizontalDivider() PreferenceSwitch( title = stringResource(id = R.string.embed_subtitles), description = stringResource(id = R.string.embed_subtitles_desc), isChecked = embedSubtitle, enabled = !downloadAudio, onClick = { if (embedSubtitle) { embedSubtitle = false EMBED_SUBTITLE.updateBoolean(false) } else { showEmbedSubtitleDialog = true } }, icon = Icons.Outlined.Subtitles, ) } item { Column { var keepSubtitles by KEEP_SUBTITLE_FILES.booleanState PreferenceSwitch( title = stringResource(id = R.string.keep_subtitle_files), description = null, isChecked = keepSubtitles, enabled = !downloadAudio && embedSubtitle, onClick = { keepSubtitles = !keepSubtitles KEEP_SUBTITLE_FILES.updateBoolean(keepSubtitles) }, icon = Icons.Outlined.Save, ) } } item { if (hint.isNotEmpty()) PreferenceInfo(text = hint) } } }, ) if (showLanguageDialog) SubtitleLanguageDialog { showLanguageDialog = false } if (showConversionDialog) SubtitleConversionDialog { showConversionDialog = false } if (showEmbedSubtitleDialog) { AlertDialog( onDismissRequest = { showEmbedSubtitleDialog = false }, icon = { Icon(Icons.Outlined.Subtitles, null) }, confirmButton = { ConfirmButton { embedSubtitle = true EMBED_SUBTITLE.updateBoolean(true) showEmbedSubtitleDialog = false } }, dismissButton = { DismissButton { showEmbedSubtitleDialog = false } }, text = { Text(stringResource(id = R.string.embed_subtitles_mkv_msg)) }, title = { Text( stringResource(id = R.string.enable_experimental_feature), textAlign = TextAlign.Center, ) }, ) } if (showAutoTranslateDialog) { AlertDialog( onDismissRequest = { showAutoTranslateDialog = false }, icon = { Icon(Icons.Outlined.Translate, null) }, confirmButton = { ConfirmButton { autoTranslatedSubtitle = true AUTO_TRANSLATED_SUBTITLES.updateBoolean(true) showAutoTranslateDialog = false } }, dismissButton = { DismissButton { showAutoTranslateDialog = false } }, text = { Text(stringResource(id = R.string.auto_translated_subtitles_msg)) }, title = { Text( stringResource(id = R.string.enable_experimental_feature), textAlign = TextAlign.Center, ) }, ) } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/general/AdvancedSettingDialogs.kt ================================================ package com.junkfood.seal.ui.page.settings.general import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.MoneyOff import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import com.junkfood.seal.R import com.junkfood.seal.ui.component.ConfirmButton import com.junkfood.seal.ui.component.DismissButton import com.junkfood.seal.ui.component.LinkButton import com.junkfood.seal.ui.component.OutlinedButtonChip import com.junkfood.seal.util.PreferenceUtil import com.junkfood.seal.util.SPONSORBLOCK_CATEGORIES const val ytdlpReference = "https://github.com/yt-dlp/yt-dlp#usage-and-options" const val sponsorBlockReference = "https://github.com/yt-dlp/yt-dlp#sponsorblock-options" val sponsorBlockCategories = listOf( "sponsor", "intro", "outro", "selfpromo", "preview", "filler", "interaction", "music_offtopic", ) @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable fun SponsorBlockDialog(onDismissRequest: () -> Unit) { var categories by remember { mutableStateOf(PreferenceUtil.getSponsorBlockCategories()) } val focusManager = LocalFocusManager.current val softwareKeyboardController = LocalSoftwareKeyboardController.current AlertDialog( onDismissRequest = onDismissRequest, icon = { Icon(Icons.Outlined.MoneyOff, null) }, title = { Text(stringResource(R.string.sponsorblock)) }, text = { Column { Text( stringResource(R.string.sponsorblock_categories_desc), style = MaterialTheme.typography.bodyLarge, ) OutlinedTextField( modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 12.dp), value = categories, label = { Text(stringResource(R.string.sponsorblock_categories)) }, onValueChange = { categories = it }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), ) LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { item { OutlinedButtonChip(label = "default") { categories = "default" } } item { OutlinedButtonChip(label = "all") { categories = "all" } } sponsorBlockCategories.forEach { if (!categories.contains(it)) item { OutlinedButtonChip(label = it) { categories = categories.replace(regex = Regex("(all)|(default)"), "") categories = "$categories,$it" categories = categories.removePrefix(",") } } } } LinkButton(link = sponsorBlockReference) } }, dismissButton = { DismissButton { onDismissRequest() } }, confirmButton = { ConfirmButton { onDismissRequest() PreferenceUtil.encodeString(SPONSORBLOCK_CATEGORIES, categories) } }, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/general/GeneralDownloadPreferences.kt ================================================ package com.junkfood.seal.ui.page.settings.general import android.Manifest import android.os.Build import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Archive import androidx.compose.material.icons.outlined.DoneAll import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.HistoryToggleOff import androidx.compose.material.icons.outlined.Image import androidx.compose.material.icons.outlined.MoneyOff import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.NotificationsActive import androidx.compose.material.icons.outlined.NotificationsOff import androidx.compose.material.icons.outlined.PlaylistAddCheck import androidx.compose.material.icons.outlined.Print import androidx.compose.material.icons.outlined.PrintDisabled import androidx.compose.material.icons.outlined.RemoveDone import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Update import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.contentColorFor import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.rememberPermissionState import com.junkfood.seal.App import com.junkfood.seal.R import com.junkfood.seal.ui.common.booleanState import com.junkfood.seal.ui.component.BackButton import com.junkfood.seal.ui.component.ConfirmButton import com.junkfood.seal.ui.component.DismissButton import com.junkfood.seal.ui.component.PreferenceInfo import com.junkfood.seal.ui.component.PreferenceItem import com.junkfood.seal.ui.component.PreferenceSubtitle import com.junkfood.seal.ui.component.PreferenceSwitch import com.junkfood.seal.ui.component.PreferenceSwitchWithDivider import com.junkfood.seal.ui.component.SealDialog import com.junkfood.seal.ui.page.download.NotificationPermissionDialog import com.junkfood.seal.util.CONFIGURE import com.junkfood.seal.util.CUSTOM_COMMAND import com.junkfood.seal.util.DEBUG import com.junkfood.seal.util.DISABLE_PREVIEW import com.junkfood.seal.util.DOWNLOAD_ARCHIVE import com.junkfood.seal.util.FileUtil.getArchiveFile import com.junkfood.seal.util.NOTIFICATION import com.junkfood.seal.util.NotificationUtil import com.junkfood.seal.util.PLAYLIST import com.junkfood.seal.util.PRIVATE_MODE import com.junkfood.seal.util.PreferenceUtil import com.junkfood.seal.util.PreferenceUtil.getBoolean import com.junkfood.seal.util.PreferenceUtil.getString import com.junkfood.seal.util.PreferenceUtil.updateBoolean import com.junkfood.seal.util.SPONSORBLOCK import com.junkfood.seal.util.SUBTITLE import com.junkfood.seal.util.THUMBNAIL import com.junkfood.seal.util.ToastUtil import com.junkfood.seal.util.UpdateUtil import com.junkfood.seal.util.YT_DLP_VERSION import com.yausername.youtubedl_android.YoutubeDL import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) @Composable fun GeneralDownloadPreferences(onNavigateBack: () -> Unit, navigateToTemplate: () -> Unit) { val context = LocalContext.current val scope = rememberCoroutineScope() val hapticFeedback = LocalHapticFeedback.current var showSponsorBlockDialog by remember { mutableStateOf(false) } var showYtdlpDialog by remember { mutableStateOf(false) } var isUpdating by remember { mutableStateOf(false) } val downloadSubtitle by SUBTITLE.booleanState var displayErrorReport by DEBUG.booleanState var downloadPlaylist by remember { mutableStateOf(PLAYLIST.getBoolean()) } var isSponsorBlockEnabled by remember { mutableStateOf(SPONSORBLOCK.getBoolean()) } var downloadNotification by remember { mutableStateOf(NOTIFICATION.getBoolean()) } var isPrivateModeEnabled by remember { mutableStateOf(PRIVATE_MODE.getBoolean()) } var isPreviewDisabled by remember { mutableStateOf(DISABLE_PREVIEW.getBoolean()) } var isNotificationPermissionGranted by remember { mutableStateOf(NotificationUtil.areNotificationsEnabled()) } val notificationPermission = if (Build.VERSION.SDK_INT >= 33) rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS) { status -> if (!status) ToastUtil.makeToast(context.getString(R.string.permission_denied)) else isNotificationPermissionGranted = true } else null var useDownloadArchive by DOWNLOAD_ARCHIVE.booleanState var showClearArchiveDialog by remember { mutableStateOf(false) } var showNotificationDialog by remember { mutableStateOf(false) } var archiveFileContent by remember { mutableStateOf("") } val storagePermission = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE) val isPermissionGranted = Build.VERSION.SDK_INT > 29 || storagePermission.status == PermissionStatus.Granted val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( rememberTopAppBarState(), canScroll = { true }, ) Scaffold( modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { LargeTopAppBar( title = { Text(text = stringResource(id = R.string.general_settings)) }, navigationIcon = { BackButton { onNavigateBack() } }, scrollBehavior = scrollBehavior, ) }, content = { val isCustomCommandEnabled by remember { mutableStateOf(CUSTOM_COMMAND.getBoolean()) } LazyColumn(modifier = Modifier, contentPadding = it) { // item { // SettingTitle(text = stringResource(id = // R.string.general_settings)) // } if (isCustomCommandEnabled) item { PreferenceInfo( text = stringResource(id = R.string.custom_command_enabled_hint) ) } item { var ytdlpVersion by remember { mutableStateOf( YoutubeDL.getInstance().version(context.applicationContext) ?: context.getString(R.string.ytdlp_update) ) } PreferenceItem( title = stringResource(id = R.string.ytdlp_update_action), description = ytdlpVersion, leadingIcon = { if (isUpdating) UpdateProgressIndicator() else { Icon( imageVector = Icons.Outlined.Update, contentDescription = null, modifier = Modifier.padding(start = 8.dp, end = 16.dp).size(24.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } }, onClick = { scope.launch { runCatching { isUpdating = true UpdateUtil.updateYtDlp() ytdlpVersion = YT_DLP_VERSION.getString() } .onFailure { th -> th.printStackTrace() ToastUtil.makeToastSuspend( App.context.getString(R.string.yt_dlp_update_fail) ) } .onSuccess { ToastUtil.makeToastSuspend( context.getString(R.string.yt_dlp_up_to_date) + " (${YT_DLP_VERSION.getString()})" ) } isUpdating = false } }, onClickLabel = stringResource(id = R.string.update), trailingIcon = { IconButton(onClick = { showYtdlpDialog = true }) { Icon( imageVector = Icons.Outlined.Settings, contentDescription = stringResource(id = R.string.open_settings), ) } }, ) } item { PreferenceSwitch( title = stringResource(id = R.string.download_notification), description = stringResource( id = if (isNotificationPermissionGranted) R.string.download_notification_desc else R.string.permission_denied ), icon = if (!isNotificationPermissionGranted) Icons.Outlined.NotificationsOff else if (!downloadNotification) Icons.Outlined.Notifications else Icons.Outlined.NotificationsActive, isChecked = downloadNotification && isNotificationPermissionGranted, onClick = { if (notificationPermission?.status is PermissionStatus.Denied) { showNotificationDialog = true } else if (isNotificationPermissionGranted) { if (downloadNotification) NotificationUtil.cancelAllNotifications() downloadNotification = !downloadNotification PreferenceUtil.updateValue(NOTIFICATION, downloadNotification) } }, ) } item { var configureBeforeDownload by CONFIGURE.booleanState PreferenceSwitch( title = stringResource(id = R.string.settings_before_download), description = stringResource(id = R.string.settings_before_download_desc), icon = if (configureBeforeDownload) Icons.Outlined.DoneAll else Icons.Outlined.RemoveDone, isChecked = configureBeforeDownload, onClick = { configureBeforeDownload = !configureBeforeDownload PreferenceUtil.updateValue(CONFIGURE, configureBeforeDownload) }, ) } item { var thumbnailSwitch by remember { mutableStateOf(THUMBNAIL.getBoolean()) } PreferenceSwitch( title = stringResource(id = R.string.create_thumbnail), description = stringResource(id = R.string.create_thumbnail_summary), enabled = !isCustomCommandEnabled, icon = Icons.Outlined.Image, isChecked = thumbnailSwitch, onClick = { thumbnailSwitch = !thumbnailSwitch PreferenceUtil.updateValue(THUMBNAIL, thumbnailSwitch) }, ) } item { PreferenceSwitch( title = stringResource(R.string.print_details), description = stringResource(R.string.print_details_desc), icon = if (displayErrorReport) Icons.Outlined.Print else Icons.Outlined.PrintDisabled, enabled = !isCustomCommandEnabled, onClick = { displayErrorReport = !displayErrorReport PreferenceUtil.updateValue(DEBUG, displayErrorReport) }, isChecked = displayErrorReport, ) } item { PreferenceSubtitle(text = stringResource(id = R.string.privacy)) } item { PreferenceSwitch( title = stringResource(R.string.private_mode), description = stringResource(R.string.private_mode_desc), icon = if (isPrivateModeEnabled) Icons.Outlined.HistoryToggleOff else Icons.Outlined.History, isChecked = isPrivateModeEnabled, enabled = !isCustomCommandEnabled, onClick = { isPrivateModeEnabled = !isPrivateModeEnabled PreferenceUtil.updateValue(PRIVATE_MODE, isPrivateModeEnabled) }, ) } item { PreferenceSwitch( title = stringResource(R.string.disable_preview), description = stringResource(R.string.disable_preview_desc), icon = if (isPreviewDisabled) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, isChecked = isPreviewDisabled, enabled = !isCustomCommandEnabled, onClick = { isPreviewDisabled = !isPreviewDisabled PreferenceUtil.updateValue(DISABLE_PREVIEW, isPreviewDisabled) }, ) } item { PreferenceSubtitle(text = stringResource(R.string.advanced_settings)) } item { PreferenceSwitch( title = stringResource(id = R.string.download_playlist), onClick = { downloadPlaylist = !downloadPlaylist PreferenceUtil.updateValue(PLAYLIST, downloadPlaylist) }, icon = Icons.Outlined.PlaylistAddCheck, enabled = !isCustomCommandEnabled, description = stringResource(R.string.download_playlist_desc), isChecked = downloadPlaylist, ) } item { PreferenceSwitchWithDivider( title = stringResource(id = R.string.download_archive), onClick = { scope.launch(Dispatchers.IO) { archiveFileContent = context.getArchiveFile().readText() withContext(Dispatchers.Main) { showClearArchiveDialog = true } } }, icon = Icons.Outlined.Archive, description = stringResource(R.string.download_archive_desc), isChecked = useDownloadArchive, onChecked = { useDownloadArchive = !useDownloadArchive DOWNLOAD_ARCHIVE.updateBoolean(useDownloadArchive) }, enabled = isPermissionGranted, ) } item { PreferenceSwitchWithDivider( title = stringResource(R.string.sponsorblock), description = stringResource(R.string.sponsorblock_desc), icon = Icons.Outlined.MoneyOff, enabled = !isCustomCommandEnabled, isChecked = isSponsorBlockEnabled, onChecked = { isSponsorBlockEnabled = !isSponsorBlockEnabled PreferenceUtil.updateValue(SPONSORBLOCK, isSponsorBlockEnabled) }, onClick = { showSponsorBlockDialog = true }, ) } if (downloadSubtitle) item { PreferenceInfo(text = stringResource(id = R.string.subtitle_sponsorblock)) } } }, ) if (showSponsorBlockDialog) { SponsorBlockDialog { showSponsorBlockDialog = false } } if (showYtdlpDialog) { YtdlpUpdateChannelDialog(onDismissRequest = { showYtdlpDialog = false }) } if (showClearArchiveDialog) { DownloadArchiveDialog( archiveFileContent = archiveFileContent, onDismissRequest = { showClearArchiveDialog = false }, ) { content -> scope.launch(Dispatchers.IO) { runCatching { context.getArchiveFile().writeText(content) } } } } if (showNotificationDialog) { NotificationPermissionDialog( onDismissRequest = { showNotificationDialog = false }, onPermissionGranted = { notificationPermission?.launchPermissionRequest() NOTIFICATION.updateBoolean(true) downloadNotification = true showNotificationDialog = false }, ) } } @Composable private fun DialogSingleChoiceItem( modifier: Modifier = Modifier, text: String, selected: Boolean, label: String, labelContainerColor: Color = MaterialTheme.colorScheme.primary, onClick: () -> Unit, ) { Row( modifier = modifier .fillMaxWidth() .selectable(selected = selected, enabled = true, onClick = onClick) .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start, ) { RadioButton( modifier = Modifier.clearAndSetSemantics {}, selected = selected, onClick = onClick, ) Text(text = text, style = MaterialTheme.typography.bodyLarge) Spacer(modifier = Modifier.weight(1f)) Surface(modifier.padding(end = 12.dp), shape = CircleShape, color = labelContainerColor) { Text( modifier = Modifier.padding(4.dp), text = label, color = MaterialTheme.colorScheme.contentColorFor(labelContainerColor), style = MaterialTheme.typography.labelSmall, ) } } } @Composable fun DialogCheckBoxItem( modifier: Modifier = Modifier, text: String, checked: Boolean, onValueChange: (Boolean) -> Unit, ) { Row( modifier = modifier .fillMaxWidth() .toggleable(value = checked, enabled = true, onValueChange = onValueChange) .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Checkbox( modifier = Modifier.clearAndSetSemantics {}, checked = checked, onCheckedChange = onValueChange, ) Text( modifier = Modifier.weight(1f), text = text, style = MaterialTheme.typography.bodyMedium, ) } } @Composable private fun UpdateProgressIndicator() { CircularProgressIndicator( modifier = Modifier.padding(start = 8.dp, end = 16.dp).size(24.dp).padding(2.dp) ) } @Composable fun DownloadArchiveDialog( archiveFileContent: String, onDismissRequest: () -> Unit, onSaveChangesCallback: (String) -> Unit, ) { var editContent by remember { mutableStateOf(archiveFileContent) } SealDialog( onDismissRequest = onDismissRequest, confirmButton = { ConfirmButton(text = stringResource(id = R.string.save)) { onSaveChangesCallback(editContent) onDismissRequest() } }, dismissButton = { DismissButton { onDismissRequest() } }, icon = { Icon(imageVector = Icons.Outlined.Edit, contentDescription = null) }, title = { Text(text = stringResource(id = R.string.edit_file)) }, text = { Column(modifier = Modifier.padding(horizontal = 24.dp)) { val textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace) OutlinedTextField( label = { Text(text = "archive.txt") }, value = editContent, onValueChange = { str -> editContent = str }, textStyle = textStyle, minLines = 10, maxLines = 10, ) } }, ) } @Composable @Preview fun DownloadArchiveDialogPreview() { val strs = buildList { repeat(20) { add("youtube IPf4AxotvNU") } } val str = strs.fold(initial = "") { acc, text -> acc + text + "\n" } DownloadArchiveDialog(archiveFileContent = str, onDismissRequest = {}) {} } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/general/YtdlpUpdateDialog.kt ================================================ package com.junkfood.seal.ui.page.settings.general import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.SyncAlt import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.dp import com.junkfood.seal.R import com.junkfood.seal.ui.common.booleanState import com.junkfood.seal.ui.common.intState import com.junkfood.seal.ui.component.ConfirmButton import com.junkfood.seal.ui.component.DismissButton import com.junkfood.seal.ui.component.SealDialog import com.junkfood.seal.util.PreferenceStrings.getUpdateIntervalText import com.junkfood.seal.util.PreferenceUtil.getLong import com.junkfood.seal.util.PreferenceUtil.updateBoolean import com.junkfood.seal.util.PreferenceUtil.updateInt import com.junkfood.seal.util.PreferenceUtil.updateLong import com.junkfood.seal.util.UpdateIntervalList import com.junkfood.seal.util.YT_DLP_AUTO_UPDATE import com.junkfood.seal.util.YT_DLP_NIGHTLY import com.junkfood.seal.util.YT_DLP_STABLE import com.junkfood.seal.util.YT_DLP_UPDATE_CHANNEL import com.junkfood.seal.util.YT_DLP_UPDATE_INTERVAL import kotlin.collections.component1 import kotlin.collections.component2 import kotlin.collections.iterator @Composable private fun DialogSingleChoiceItem( modifier: Modifier = Modifier, text: String, selected: Boolean, label: String, labelContainerColor: Color = MaterialTheme.colorScheme.primary, onClick: () -> Unit, ) { Row( modifier = modifier .fillMaxWidth() .selectable(selected = selected, enabled = true, onClick = onClick) .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start, ) { RadioButton( modifier = Modifier.clearAndSetSemantics {}, selected = selected, onClick = onClick, ) Text(text = text, style = MaterialTheme.typography.bodyLarge) Spacer(modifier = Modifier.weight(1f)) Surface(modifier.padding(end = 12.dp), shape = CircleShape, color = labelContainerColor) { Text( modifier = Modifier.padding(4.dp), text = label, color = MaterialTheme.colorScheme.contentColorFor(labelContainerColor), style = MaterialTheme.typography.labelSmall, ) } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun YtdlpUpdateChannelDialog(modifier: Modifier = Modifier, onDismissRequest: () -> Unit) { var ytdlpUpdateChannel by YT_DLP_UPDATE_CHANNEL.intState var ytdlpAutoUpdate by YT_DLP_AUTO_UPDATE.booleanState var updateInterval by remember { mutableLongStateOf(YT_DLP_UPDATE_INTERVAL.getLong()) } SealDialog( modifier = modifier, onDismissRequest = onDismissRequest, confirmButton = { ConfirmButton { YT_DLP_AUTO_UPDATE.updateBoolean(ytdlpAutoUpdate) YT_DLP_UPDATE_CHANNEL.updateInt(ytdlpUpdateChannel) YT_DLP_UPDATE_INTERVAL.updateLong(updateInterval) onDismissRequest() } }, dismissButton = { DismissButton { onDismissRequest() } }, title = { Text(text = stringResource(id = R.string.update)) }, icon = { Icon(Icons.Outlined.SyncAlt, null) }, text = { LazyColumn() { item { Text( text = stringResource(id = R.string.update_channel), modifier = Modifier.fillMaxWidth() .padding(horizontal = 24.dp) .padding(top = 16.dp, bottom = 8.dp), color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelLarge, ) } item { DialogSingleChoiceItem( text = "yt-dlp", selected = ytdlpUpdateChannel == YT_DLP_STABLE, label = "Stable", ) { ytdlpUpdateChannel = YT_DLP_STABLE } } item { DialogSingleChoiceItem( text = "yt-dlp-nightly-builds", selected = ytdlpUpdateChannel == YT_DLP_NIGHTLY, label = "Nightly", labelContainerColor = MaterialTheme.colorScheme.tertiary, ) { ytdlpUpdateChannel = YT_DLP_NIGHTLY } } item { Text( text = stringResource(id = R.string.additional_settings), modifier = Modifier.fillMaxWidth() .padding(horizontal = 24.dp) .padding(top = 16.dp, bottom = 16.dp), color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelLarge, ) } item { var expanded by remember { mutableStateOf(false) } ExposedDropdownMenuBox( modifier = Modifier.padding(horizontal = 20.dp), expanded = expanded, onExpandedChange = { expanded = it }, ) { OutlinedTextField( value = if (!ytdlpAutoUpdate) stringResource(id = R.string.disabled) else getUpdateIntervalText(updateInterval), onValueChange = {}, label = { Text(text = stringResource(id = R.string.auto_update)) }, readOnly = true, modifier = Modifier.fillMaxWidth() .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable), colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, ) ExposedDropdownMenu( modifier = Modifier.verticalScroll(rememberScrollState()), expanded = expanded, onDismissRequest = { expanded = false }, ) { DropdownMenuItem( text = { Text(stringResource(id = R.string.disabled)) }, onClick = { ytdlpAutoUpdate = false expanded = false }, ) for ((interval, stringId) in UpdateIntervalList) { DropdownMenuItem( text = { Text(text = stringResource(id = stringId)) }, onClick = { ytdlpAutoUpdate = true updateInterval = interval expanded = false }, ) } } } } } }, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/interaction/InteractionPreferencePage.kt ================================================ package com.junkfood.seal.ui.page.settings.interaction import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import com.junkfood.seal.R import com.junkfood.seal.ui.component.BackButton import com.junkfood.seal.ui.component.PreferenceItem import com.junkfood.seal.ui.component.PreferenceSubtitle import com.junkfood.seal.util.DOWNLOAD_TYPE_INITIALIZATION import com.junkfood.seal.util.PreferenceUtil.getInt import com.junkfood.seal.util.PreferenceUtil.updateInt import com.junkfood.seal.util.USE_PREVIOUS_SELECTION @OptIn(ExperimentalMaterial3Api::class) @Composable fun InteractionPreferencePage(modifier: Modifier = Modifier, onBack: () -> Unit) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() var showDownloadTypeDialog by remember { mutableStateOf(false) } val initialType by remember(showDownloadTypeDialog) { mutableIntStateOf(DOWNLOAD_TYPE_INITIALIZATION.getInt()) } Scaffold( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { LargeTopAppBar( title = { Text(text = stringResource(id = R.string.interface_and_interaction)) }, scrollBehavior = scrollBehavior, navigationIcon = { BackButton(onClick = onBack) }, ) }, ) { LazyColumn(modifier = Modifier, contentPadding = it) { item { PreferenceSubtitle(text = stringResource(id = R.string.settings_before_download)) } item { PreferenceItem( title = stringResource(id = R.string.download_type), description = when (initialType) { USE_PREVIOUS_SELECTION -> stringResource(id = R.string.use_previous_selection) else -> stringResource(id = R.string.none) }, ) { showDownloadTypeDialog = true } } } } if (showDownloadTypeDialog) { DownloadTypeCustomizationDialog( onDismissRequest = { showDownloadTypeDialog = false }, selectedItem = initialType, ) { DOWNLOAD_TYPE_INITIALIZATION.updateInt(it) showDownloadTypeDialog = false } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/interaction/InterfaceCustomizationDialogs.kt ================================================ package com.junkfood.seal.ui.page.settings.interaction import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.junkfood.seal.R import com.junkfood.seal.ui.component.DialogSingleChoiceItem import com.junkfood.seal.ui.component.SealDialog import com.junkfood.seal.util.NONE import com.junkfood.seal.util.USE_PREVIOUS_SELECTION @Composable fun DownloadTypeCustomizationDialog( modifier: Modifier = Modifier, onDismissRequest: () -> Unit, selectedItem: Int, onSelect: (Int) -> Unit, ) { SealDialog( modifier = modifier, onDismissRequest = onDismissRequest, confirmButton = null, title = { Text(text = stringResource(id = R.string.download_type)) }, text = { LazyColumn(modifier = Modifier.padding()) { item { DialogSingleChoiceItem( text = stringResource(id = R.string.use_previous_selection), selected = selectedItem == USE_PREVIOUS_SELECTION, ) { onSelect(USE_PREVIOUS_SELECTION) } } item { DialogSingleChoiceItem( text = stringResource(id = R.string.none), selected = selectedItem == NONE, ) { onSelect(NONE) } } } }, ) } @Preview @Composable private fun Preview() { DownloadTypeCustomizationDialog(onDismissRequest = {}, selectedItem = NONE) {} } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/network/CookieProfilesPage.kt ================================================ package com.junkfood.seal.ui.page.settings.network import android.content.res.Configuration import android.webkit.CookieManager import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Cookie import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.DeleteForever import androidx.compose.material.icons.outlined.FileCopy import androidx.compose.material.icons.outlined.GeneratingTokens import androidx.compose.material.icons.outlined.HelpOutline import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.junkfood.seal.R import com.junkfood.seal.database.objects.CookieProfile import com.junkfood.seal.ui.common.HapticFeedback.slightHapticFeedback import com.junkfood.seal.ui.common.booleanState import com.junkfood.seal.ui.component.BackButton import com.junkfood.seal.ui.component.ConfirmButton import com.junkfood.seal.ui.component.DialogSwitchItem import com.junkfood.seal.ui.component.DismissButton import com.junkfood.seal.ui.component.HelpDialog import com.junkfood.seal.ui.component.PasteFromClipBoardButton import com.junkfood.seal.ui.component.PreferenceItemVariant import com.junkfood.seal.ui.component.PreferenceSwitchWithContainer import com.junkfood.seal.ui.component.SealDialog import com.junkfood.seal.ui.component.TextButtonWithIcon import com.junkfood.seal.ui.theme.SealTheme import com.junkfood.seal.ui.theme.generateLabelColor import com.junkfood.seal.util.COOKIES import com.junkfood.seal.util.DownloadUtil import com.junkfood.seal.util.DownloadUtil.toCookiesFileContent import com.junkfood.seal.util.FileUtil import com.junkfood.seal.util.FileUtil.getCookiesFile import com.junkfood.seal.util.PreferenceUtil.getBoolean import com.junkfood.seal.util.PreferenceUtil.updateBoolean import com.junkfood.seal.util.USER_AGENT import com.junkfood.seal.util.matchUrlFromClipboard import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @Composable fun CookieProfilePage( cookiesViewModel: CookiesViewModel, navigateToCookieGeneratorPage: () -> Unit = {}, onNavigateBack: () -> Unit = {}, ) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( rememberTopAppBarState(), canScroll = { true }, ) val cookies = cookiesViewModel.cookiesFlow.collectAsState(emptyList()).value val scope = rememberCoroutineScope() val hapticFeedback = LocalHapticFeedback.current val context = LocalContext.current val clipboardManager = LocalClipboardManager.current val state by cookiesViewModel.stateFlow.collectAsStateWithLifecycle() var showClearCookieDialog by remember { mutableStateOf(false) } var isCookieEnabled by remember { mutableStateOf(COOKIES.getBoolean()) } val cookieManager = CookieManager.getInstance() var showHelpDialog by remember { mutableStateOf(false) } val view = LocalView.current var cookieList by remember { mutableStateOf(listOf()) } var shouldUpdateCookies by remember { mutableStateOf(false) } var showEditDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) } DisposableEffect(shouldUpdateCookies) { scope.launch(Dispatchers.IO) { DownloadUtil.getCookieListFromDatabase().getOrNull()?.let { cookieList = it FileUtil.writeContentToFile(it.toCookiesFileContent(), context.getCookiesFile()) } } onDispose { shouldUpdateCookies = false } } val exportLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument("text/plain") ) { uri -> uri?.let { scope.launch(Dispatchers.IO) { context.contentResolver.openOutputStream(uri)?.use { it.write(cookieList.toCookiesFileContent().toByteArray()) } } } } Scaffold( modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { LargeTopAppBar( title = { Text(modifier = Modifier, text = stringResource(id = R.string.cookies)) }, navigationIcon = { BackButton { onNavigateBack() } }, actions = { var expanded by remember { mutableStateOf(false) } IconButton(onClick = { showHelpDialog = true }) { Icon( imageVector = Icons.Outlined.HelpOutline, contentDescription = stringResource(R.string.how_does_it_work), ) } IconButton(onClick = { expanded = true }) { Icon( Icons.Outlined.MoreVert, contentDescription = stringResource(R.string.show_more_actions), ) } DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { var userAgent by USER_AGENT.booleanState fun toggleUserAgent(boolean: Boolean = !userAgent) { expanded = false userAgent = boolean USER_AGENT.updateBoolean(boolean) } DropdownMenuItem( modifier = Modifier.toggleable( value = userAgent, onValueChange = ::toggleUserAgent, ), leadingIcon = { Checkbox( checked = userAgent, onCheckedChange = null, modifier = Modifier.clearAndSetSemantics {}, ) }, text = { Text(stringResource(id = R.string.ua_header)) }, onClick = ::toggleUserAgent, ) DropdownMenuItem( leadingIcon = { Icon(Icons.Outlined.FileCopy, null) }, text = { Text(stringResource(id = R.string.export_to_file)) }, enabled = cookieList.isNotEmpty(), onClick = { expanded = false exportLauncher.launch( "cookies_exported${System.currentTimeMillis()}.txt" ) }, ) DropdownMenuItem( leadingIcon = { Icon(Icons.Outlined.DeleteForever, null) }, text = { Text(stringResource(id = R.string.clear_all_cookies)) }, onClick = { expanded = false hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) showClearCookieDialog = true }, ) } }, scrollBehavior = scrollBehavior, ) }, ) { paddingValues -> LazyColumn(modifier = Modifier, contentPadding = paddingValues) { item { PreferenceSwitchWithContainer( title = stringResource(R.string.use_cookies), icon = null, isChecked = isCookieEnabled, onClick = { if (isCookieEnabled) { isCookieEnabled = false COOKIES.updateBoolean(false) } else if ( (cookies.isEmpty() || !cookieManager.hasCookies()) && !isCookieEnabled ) { showHelpDialog = true } else { isCookieEnabled = true COOKIES.updateBoolean(true) } }, ) } itemsIndexed(cookies) { _, item -> PreferenceItemVariant( modifier = Modifier.padding(vertical = 4.dp), title = item.url, onClick = { cookiesViewModel.setEditingProfile(item) showEditDialog = true }, onClickLabel = stringResource(id = R.string.edit), onLongClick = { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) cookiesViewModel.setEditingProfile(item) showDeleteDialog = true }, onLongClickLabel = stringResource(R.string.remove), ) } item { PreferenceItemVariant( title = stringResource(id = R.string.generate_new_cookies), icon = Icons.Outlined.Add, ) { cookiesViewModel.setEditingProfile() showEditDialog = true } } item { androidx.compose.material3.HorizontalDivider() val cookiesCount = cookieList.size val siteCount = cookieList.distinctBy { it.domain }.size Text( text = stringResource(R.string.cookies_in_database, cookiesCount, siteCount), style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } if (showEditDialog) { CookieGeneratorDialog( cookiesViewModel = cookiesViewModel, navigateToCookieGeneratorPage = { cookiesViewModel.updateCookieProfile() navigateToCookieGeneratorPage() }, ) { showEditDialog = false shouldUpdateCookies = true } } if (showDeleteDialog) { DeleteCookieDialog(cookiesViewModel) { showDeleteDialog = false } } if (showHelpDialog) { HelpDialog( text = stringResource(id = R.string.cookies_usage_msg), onDismissRequest = { showHelpDialog = false }, ) } if (showClearCookieDialog) { ClearCookiesDialog(onDismissRequest = { showClearCookieDialog = false }) { view.slightHapticFeedback() scope .launch(Dispatchers.IO) { CookieManager.getInstance().removeAllCookies(null) } .invokeOnCompletion { shouldUpdateCookies = true } } } } @Composable fun CookieGeneratorDialog( cookiesViewModel: CookiesViewModel, navigateToCookieGeneratorPage: () -> Unit = {}, onDismissRequest: () -> Unit, ) { val state by cookiesViewModel.stateFlow.collectAsStateWithLifecycle() val profile = state.editingCookieProfile val url = profile.url LaunchedEffect(Unit) { withContext(Dispatchers.IO) { CookieManager.getInstance().flush() } } AlertDialog( onDismissRequest = onDismissRequest, icon = { Icon(Icons.Outlined.Cookie, null) }, title = { Text(stringResource(R.string.cookies)) }, text = { Column(Modifier.verticalScroll(rememberScrollState())) { OutlinedTextField( modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), value = url, label = { Text("URL") }, onValueChange = { cookiesViewModel.updateUrl(it) }, trailingIcon = { PasteFromClipBoardButton { cookiesViewModel.updateUrl(matchUrlFromClipboard(it)) } }, maxLines = 1, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), ) TextButtonWithIcon( onClick = { navigateToCookieGeneratorPage() }, icon = Icons.Outlined.GeneratingTokens, text = stringResource(id = R.string.generate_new_cookies), ) } }, dismissButton = { DismissButton { onDismissRequest() } }, confirmButton = { ConfirmButton(enabled = url.isNotEmpty()) { cookiesViewModel.updateCookieProfile() onDismissRequest() } }, ) } @Composable fun DeleteCookieDialog(cookiesViewModel: CookiesViewModel, onDismissRequest: () -> Unit = {}) { val state by cookiesViewModel.stateFlow.collectAsState() AlertDialog( onDismissRequest = onDismissRequest, title = { Text(stringResource(R.string.remove)) }, text = { Text( stringResource(R.string.remove_cookie_profile_desc) .format(state.editingCookieProfile.url), style = LocalTextStyle.current.copy(lineBreak = LineBreak.Paragraph), ) }, dismissButton = { DismissButton { onDismissRequest() } }, confirmButton = { ConfirmButton { cookiesViewModel.deleteCookieProfile() onDismissRequest() } }, icon = { Icon(Icons.Outlined.Delete, null) }, ) } @Composable fun ClearCookiesDialog(onDismissRequest: () -> Unit = {}, onConfirm: () -> Unit) { AlertDialog( onDismissRequest = onDismissRequest, title = { Text(stringResource(R.string.clear_all_cookies)) }, text = { Text( stringResource(R.string.clear_all_cookies_desc), style = MaterialTheme.typography.bodyLarge, ) }, dismissButton = { DismissButton { onDismissRequest() } }, confirmButton = { ConfirmButton { onConfirm() onDismissRequest() } }, icon = { Icon(Icons.Outlined.DeleteForever, null) }, ) } @Composable fun CookiesQuickSettingsDialog( onDismissRequest: () -> Unit = {}, onConfirm: () -> Unit = {}, cookieProfiles: List = emptyList(), onCookieProfileClicked: (CookieProfile) -> Unit = {}, isCookiesEnabled: Boolean = false, onCookiesToggled: (Boolean) -> Unit = {}, ) { SealDialog( onDismissRequest = onDismissRequest, confirmButton = { ConfirmButton( text = stringResource(id = androidx.appcompat.R.string.abc_action_mode_done) ) { onDismissRequest() onConfirm() } }, icon = { Icon(imageVector = Icons.Outlined.Cookie, contentDescription = null) }, title = { Text(text = stringResource(id = R.string.cookies), textAlign = TextAlign.Center) }, text = { Column { Text( text = stringResource(id = R.string.refresh_cookies_desc), modifier = Modifier.padding(horizontal = 24.dp), // style = MaterialTheme.typography.labelLarge, ) Spacer(modifier = Modifier.height(12.dp)) androidx.compose.material3.HorizontalDivider( modifier = Modifier.padding(horizontal = 24.dp) ) Spacer(modifier = Modifier.height(4.dp)) LazyColumn() { items(items = cookieProfiles) { Row( modifier = Modifier.fillMaxWidth() .clickable { onCookieProfileClicked(it) } .padding(horizontal = 24.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Box( modifier = Modifier.padding(end = 12.dp) .size(16.dp) .background( color = it.url.hashCode().generateLabelColor(), shape = CircleShape, ) .clearAndSetSemantics {} ) {} Text( text = it.url // , style = // MaterialTheme.typography.labelLarge , modifier = Modifier.weight(1f), ) } } } Spacer(modifier = Modifier.height(4.dp)) androidx.compose.material3.HorizontalDivider( modifier = Modifier.padding(horizontal = 24.dp) ) DialogSwitchItem( text = stringResource(id = R.string.use_cookies), value = isCookiesEnabled, onValueChange = onCookiesToggled, ) } }, ) } @Preview @Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun CookiesQuickSettingsDialogPreview() { SealTheme { var isCookiesEnabled by remember { mutableStateOf(false) } CookiesQuickSettingsDialog( cookieProfiles = mutableListOf().apply { repeat(4) { add( CookieProfile(id = it, url = "https://www.example$it.com", content = "") ) } }, isCookiesEnabled = isCookiesEnabled, onCookiesToggled = { isCookiesEnabled = it }, ) } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/network/CookiesViewModel.kt ================================================ package com.junkfood.seal.ui.page.settings.network import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.junkfood.seal.database.objects.CookieProfile import com.junkfood.seal.util.DatabaseUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class CookiesViewModel : ViewModel() { companion object { const val NEW_PROFILE_ID = 0 } data class ViewState( val editingCookieProfile: CookieProfile = CookieProfile(id = NEW_PROFILE_ID, url = "", content = "") ) val cookiesFlow = DatabaseUtil.getCookiesFlow() private val mutableStateFlow = MutableStateFlow(ViewState()) val stateFlow = mutableStateFlow.asStateFlow() private val state get() = stateFlow.value fun setEditingProfile( cookieProfile: CookieProfile = CookieProfile(id = NEW_PROFILE_ID, url = "https://", content = "") ) { mutableStateFlow.update { it.copy(editingCookieProfile = cookieProfile) } } fun deleteCookieProfile(cookieProfile: CookieProfile = state.editingCookieProfile) { viewModelScope.launch(Dispatchers.IO) { DatabaseUtil.deleteCookieProfile(cookieProfile) } } fun generateNewCookies(content: String) { viewModelScope.launch(Dispatchers.IO) { mutableStateFlow.update { val newProfile = it.editingCookieProfile.copy(content = content) DatabaseUtil.updateCookieProfile(newProfile) it.copy(editingCookieProfile = newProfile) } } } fun updateUrl(url: String) { setEditingProfile(cookieProfile = state.editingCookieProfile.copy(url = url)) } fun updateContent(content: String) = mutableStateFlow.update { it.copy(editingCookieProfile = it.editingCookieProfile.copy(content = content)) } fun updateCookieProfile(profile: CookieProfile = state.editingCookieProfile) { viewModelScope.launch(Dispatchers.IO) { if (profile.id == NEW_PROFILE_ID) { DatabaseUtil.insertCookieProfile(profile) } else { DatabaseUtil.updateCookieProfile(profile) } } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/network/NetworkPreferences.kt ================================================ package com.junkfood.seal.ui.page.settings.network import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.icons.outlined.Cookie import androidx.compose.material.icons.outlined.OfflineBolt import androidx.compose.material.icons.outlined.SettingsEthernet import androidx.compose.material.icons.outlined.SignalCellular4Bar import androidx.compose.material.icons.outlined.SignalCellularConnectedNoInternet4Bar import androidx.compose.material.icons.outlined.Speed import androidx.compose.material.icons.outlined.VpnKey import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import com.junkfood.seal.R import com.junkfood.seal.ui.common.booleanState import com.junkfood.seal.ui.component.BackButton import com.junkfood.seal.ui.component.PreferenceInfo import com.junkfood.seal.ui.component.PreferenceItem import com.junkfood.seal.ui.component.PreferenceSubtitle import com.junkfood.seal.ui.component.PreferenceSwitch import com.junkfood.seal.ui.component.PreferenceSwitchWithDivider import com.junkfood.seal.util.ARIA2C import com.junkfood.seal.util.CELLULAR_DOWNLOAD import com.junkfood.seal.util.COOKIES import com.junkfood.seal.util.CUSTOM_COMMAND import com.junkfood.seal.util.FORCE_IPV4 import com.junkfood.seal.util.PROXY import com.junkfood.seal.util.PreferenceUtil.getBoolean import com.junkfood.seal.util.PreferenceUtil.updateBoolean import com.junkfood.seal.util.PreferenceUtil.updateValue import com.junkfood.seal.util.RATE_LIMIT @OptIn(ExperimentalMaterial3Api::class) @Composable fun NetworkPreferences(navigateToCookieProfilePage: () -> Unit = {}, onNavigateBack: () -> Unit) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( rememberTopAppBarState(), canScroll = { true }, ) var showConcurrentDownloadDialog by remember { mutableStateOf(false) } var showRateLimitDialog by remember { mutableStateOf(false) } var showProxyDialog by remember { mutableStateOf(false) } var aria2c by remember { mutableStateOf(ARIA2C.getBoolean()) } var proxy by PROXY.booleanState var isCookiesEnabled by COOKIES.booleanState var forceIpv4 by FORCE_IPV4.booleanState Scaffold( modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { LargeTopAppBar( title = { Text(modifier = Modifier, text = stringResource(id = R.string.network)) }, navigationIcon = { BackButton { onNavigateBack() } }, scrollBehavior = scrollBehavior, ) }, content = { val isCustomCommandEnabled by CUSTOM_COMMAND.booleanState LazyColumn(contentPadding = it) { if (isCustomCommandEnabled) item { PreferenceInfo( text = stringResource(id = R.string.custom_command_enabled_hint) ) } item { PreferenceSubtitle(text = stringResource(R.string.general_settings)) } item { var isRateLimitEnabled by remember { mutableStateOf(RATE_LIMIT.getBoolean()) } PreferenceSwitchWithDivider( title = stringResource(R.string.rate_limit), description = stringResource(R.string.rate_limit_desc), icon = Icons.Outlined.Speed, enabled = !isCustomCommandEnabled, isChecked = isRateLimitEnabled, onChecked = { isRateLimitEnabled = !isRateLimitEnabled updateValue(RATE_LIMIT, isRateLimitEnabled) }, onClick = { showRateLimitDialog = true }, ) } item { var isDownloadWithCellularEnabled by remember { mutableStateOf(CELLULAR_DOWNLOAD.getBoolean()) } PreferenceSwitch( title = stringResource(R.string.download_with_cellular), description = stringResource(R.string.download_with_cellular_desc), icon = if (isDownloadWithCellularEnabled) Icons.Outlined.SignalCellular4Bar else Icons.Outlined.SignalCellularConnectedNoInternet4Bar, isChecked = isDownloadWithCellularEnabled, onClick = { isDownloadWithCellularEnabled = !isDownloadWithCellularEnabled updateValue(CELLULAR_DOWNLOAD, isDownloadWithCellularEnabled) }, ) } item { PreferenceSubtitle(text = stringResource(id = R.string.advanced_settings)) } item { PreferenceSwitch( title = stringResource(R.string.aria2), icon = Icons.Outlined.Bolt, description = stringResource(R.string.aria2_desc), isChecked = aria2c, onClick = { aria2c = !aria2c updateValue(ARIA2C, aria2c) }, ) } item { PreferenceSwitchWithDivider( title = stringResource(id = R.string.proxy), description = stringResource(id = R.string.proxy_desc), icon = Icons.Outlined.VpnKey, isChecked = proxy, onChecked = { proxy = !proxy PROXY.updateBoolean(proxy) }, onClick = { showProxyDialog = true }, enabled = !isCustomCommandEnabled, ) } item { PreferenceItem( title = stringResource(id = R.string.concurrent_download), description = stringResource(R.string.concurrent_download_desc), icon = Icons.Outlined.OfflineBolt, enabled = !aria2c && !isCustomCommandEnabled, ) { showConcurrentDownloadDialog = true } } item { PreferenceSwitch( title = stringResource(R.string.force_ipv4), description = stringResource(id = R.string.force_ipv4_desc), icon = Icons.Outlined.SettingsEthernet, enabled = !isCustomCommandEnabled, isChecked = forceIpv4, ) { forceIpv4 = !forceIpv4 FORCE_IPV4.updateBoolean(forceIpv4) } } item { PreferenceItem( title = stringResource(R.string.cookies), description = stringResource(R.string.cookies_desc), icon = Icons.Outlined.Cookie, onClick = { navigateToCookieProfilePage() }, ) } } }, ) if (showConcurrentDownloadDialog) { ConcurrentDownloadDialog { showConcurrentDownloadDialog = false } } if (showRateLimitDialog) { RateLimitDialog { showRateLimitDialog = false } } if (showProxyDialog) { ProxyConfigurationDialog { showProxyDialog = false } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/network/NetworkSettingDialogs.kt ================================================ package com.junkfood.seal.ui.page.settings.network import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.OfflineBolt import androidx.compose.material.icons.outlined.Speed import androidx.compose.material.icons.outlined.VpnKey import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.core.text.isDigitsOnly import com.junkfood.seal.R import com.junkfood.seal.ui.component.ConfirmButton import com.junkfood.seal.ui.component.DismissButton import com.junkfood.seal.util.CONCURRENT import com.junkfood.seal.util.MAX_RATE import com.junkfood.seal.util.PROXY_URL import com.junkfood.seal.util.PreferenceUtil import com.junkfood.seal.util.PreferenceUtil.getString import com.junkfood.seal.util.PreferenceUtil.updateString import com.junkfood.seal.util.isNumberInRange import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable fun RateLimitDialog(onDismissRequest: () -> Unit) { var isError by remember { mutableStateOf(false) } var maxRate by remember { mutableStateOf(PreferenceUtil.getMaxDownloadRate()) } val focusManager = LocalFocusManager.current val softwareKeyboardController = LocalSoftwareKeyboardController.current AlertDialog( onDismissRequest = onDismissRequest, icon = { Icon(Icons.Outlined.Speed, null) }, title = { Text(stringResource(R.string.rate_limit)) }, text = { Column { Text( stringResource(R.string.rate_limit_desc), style = MaterialTheme.typography.bodyLarge, ) OutlinedTextField( modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 12.dp), isError = isError, supportingText = { Text( text = if (isError) stringResource(R.string.invalid_input) else "", style = MaterialTheme.typography.bodySmall, color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, ) }, value = maxRate, label = { Text(stringResource(R.string.max_rate)) }, onValueChange = { if (it.isDigitsOnly()) maxRate = it isError = false }, trailingIcon = { Text("K") }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), ) } }, dismissButton = { DismissButton { onDismissRequest() } }, confirmButton = { ConfirmButton { if (maxRate.isNumberInRange(1, 100_0000)) { PreferenceUtil.encodeString(MAX_RATE, maxRate) onDismissRequest() } else { isError = true } } }, ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun ConcurrentDownloadDialog(onDismissRequest: () -> Unit) { var concurrentFragments by remember { mutableFloatStateOf(PreferenceUtil.getConcurrentFragments()) } val count by remember { derivedStateOf { if (concurrentFragments <= 0.125f) 1 else ((concurrentFragments * 3f).roundToInt()) * 8 } } AlertDialog( onDismissRequest = onDismissRequest, dismissButton = { TextButton(onClick = onDismissRequest) { Text(stringResource(R.string.dismiss)) } }, confirmButton = { TextButton( onClick = { onDismissRequest() PreferenceUtil.encodeInt(CONCURRENT, count) } ) { Text(stringResource(R.string.confirm)) } }, icon = { Icon(Icons.Outlined.OfflineBolt, null) }, title = { Text(stringResource(R.string.concurrent_download)) }, text = { Column { val interactionSource = remember { MutableInteractionSource() } Text(text = stringResource(R.string.concurrent_download_num, count)) Spacer(modifier = Modifier.height(8.dp)) Slider( value = concurrentFragments, onValueChange = { concurrentFragments = it }, steps = 2, valueRange = 0f..1f, thumb = { SliderDefaults.Thumb( modifier = Modifier, interactionSource = interactionSource, thumbSize = DpSize(4.dp, 32.dp), ) }, ) } }, ) } @Composable fun ProxyConfigurationDialog(onDismissRequest: () -> Unit = {}) { var proxyUrl by remember { mutableStateOf(PROXY_URL.getString()) } AlertDialog( onDismissRequest = onDismissRequest, icon = { Icon(Icons.Outlined.VpnKey, null) }, title = { Text(stringResource(R.string.proxy)) }, text = { Column { Text( stringResource(R.string.proxy_desc), style = MaterialTheme.typography.bodyLarge, ) OutlinedTextField( modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 24.dp), value = proxyUrl, label = { Text(stringResource(R.string.proxy)) }, onValueChange = { proxyUrl = it }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), ) } }, dismissButton = { DismissButton { onDismissRequest() } }, confirmButton = { ConfirmButton { PROXY_URL.updateString(proxyUrl) onDismissRequest() } }, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/network/WebViewPage.kt ================================================ package com.junkfood.seal.ui.page.settings.network import android.annotation.SuppressLint import android.util.Log import android.webkit.CookieManager import android.webkit.WebResourceRequest import android.webkit.WebView import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Close import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.web.AccompanistWebChromeClient import com.google.accompanist.web.AccompanistWebViewClient import com.google.accompanist.web.WebView import com.google.accompanist.web.rememberWebViewState import com.google.android.material.R import com.junkfood.seal.util.PreferenceUtil.updateString import com.junkfood.seal.util.USER_AGENT_STRING import com.junkfood.seal.util.connectWithDelimiter private const val TAG = "WebViewPage" data class Cookie( val domain: String = "", val name: String = "", val value: String = "", val includeSubdomains: Boolean = true, val path: String = "/", val secure: Boolean = true, val expiry: Long = 0L, ) { constructor( url: String, name: String, value: String, ) : this(domain = url.toDomain(), name = name, value = value) fun toNetscapeCookieString(): String { return connectWithDelimiter( domain, includeSubdomains.toString().uppercase(), path, secure.toString().uppercase(), expiry.toString(), name, value, delimiter = "\u0009", ) } } private val domainRegex = Regex("""http(s)?://(\w*(www|m|account|sso))?|/.*""") private fun String.toDomain(): String { return this.replace(domainRegex, "") } private fun makeCookie(url: String, cookieString: String): Cookie { cookieString.split("=").run { return Cookie(url = url, name = first(), value = last()) } } @SuppressLint("SetJavaScriptEnabled") @OptIn(ExperimentalMaterial3Api::class) @Composable fun WebViewPage(cookiesViewModel: CookiesViewModel, onDismissRequest: () -> Unit) { val state by cookiesViewModel.stateFlow.collectAsStateWithLifecycle() Log.d(TAG, state.editingCookieProfile.url) val cookieManager = CookieManager.getInstance() val cookieSet = remember { mutableSetOf() } val websiteUrl = state.editingCookieProfile.url val webViewState = rememberWebViewState(websiteUrl) Scaffold( modifier = Modifier.fillMaxSize(), topBar = { TopAppBar( title = { Text(webViewState.pageTitle.toString(), maxLines = 1) }, navigationIcon = { IconButton(onClick = { onDismissRequest() }) { Icon( imageVector = Icons.Outlined.Close, stringResource(id = androidx.appcompat.R.string.abc_action_mode_done), ) } }, actions = { TextButton(onClick = onDismissRequest) { Text(text = stringResource(id = R.string.abc_action_mode_done)) } }, ) }, ) { paddingValues -> val webViewClient = remember { object : AccompanistWebViewClient() { override fun onPageFinished(view: WebView, url: String?) { super.onPageFinished(view, url) if (url.isNullOrEmpty()) return } override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest?, ): Boolean { return if (request?.url?.scheme?.contains("http") == true) super.shouldOverrideUrlLoading(view, request) else true } } } val webViewChromeClient = remember { object : AccompanistWebChromeClient() {} } WebView( state = webViewState, client = webViewClient, chromeClient = webViewChromeClient, modifier = Modifier.padding(paddingValues).fillMaxSize(), captureBackPresses = true, factory = { context -> WebView(context).apply { settings.run { javaScriptCanOpenWindowsAutomatically = true javaScriptEnabled = true domStorageEnabled = true USER_AGENT_STRING.updateString(userAgentString) } cookieManager.setAcceptThirdPartyCookies(this, true) } }, ) } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/settings/troubleshooting/TroubleshootingPage.kt ================================================ package com.junkfood.seal.ui.page.settings.troubleshooting import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.OpenInNew import androidx.compose.material.icons.outlined.Cookie import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Spellcheck import androidx.compose.material.icons.outlined.Update import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.junkfood.seal.App import com.junkfood.seal.R import com.junkfood.seal.ui.common.Route import com.junkfood.seal.ui.common.booleanState import com.junkfood.seal.ui.component.PreferenceInfo import com.junkfood.seal.ui.component.PreferenceItem import com.junkfood.seal.ui.component.PreferenceSubtitle import com.junkfood.seal.ui.component.PreferenceSwitch import com.junkfood.seal.ui.page.settings.BasePreferencePage import com.junkfood.seal.ui.page.settings.general.YtdlpUpdateChannelDialog import com.junkfood.seal.util.PreferenceUtil.getString import com.junkfood.seal.util.PreferenceUtil.updateBoolean import com.junkfood.seal.util.RESTRICT_FILENAMES import com.junkfood.seal.util.UpdateUtil import com.junkfood.seal.util.YT_DLP_VERSION import com.junkfood.seal.util.makeToast import com.yausername.youtubedl_android.YoutubeDL import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @Composable fun TroubleShootingPage( modifier: Modifier = Modifier, onNavigateTo: (String) -> Unit, onBack: () -> Unit, ) { val uriHandler = LocalUriHandler.current val context = LocalContext.current val scope = rememberCoroutineScope() BasePreferencePage( modifier = modifier, title = stringResource(R.string.trouble_shooting), onBack = onBack, ) { LazyColumn(contentPadding = it) { item { OutlinedCard(modifier = Modifier.padding(16.dp)) { PreferenceInfo( modifier = Modifier, text = stringResource(R.string.issue_tracker_hint), ) val knownIssueUrlSeal = "https://github.com/JunkFood02/Seal/issues/1399" PreferenceItem( title = "Seal Issue Tracker", description = null, icon = Icons.AutoMirrored.Outlined.OpenInNew, onClick = { uriHandler.openUri(knownIssueUrlSeal) }, ) val knownIssueUrlYtdlp = "https://github.com/yt-dlp/yt-dlp/issues/3766" PreferenceItem( title = "yt-dlp Issue Tracker", description = null, icon = Icons.AutoMirrored.Outlined.OpenInNew, onClick = { uriHandler.openUri(knownIssueUrlYtdlp) }, ) Spacer(Modifier.height(8.dp)) } } item { PreferenceSubtitle(text = stringResource(R.string.update)) } item { var isUpdating by remember { mutableStateOf(false) } var showYtdlpDialog by remember { mutableStateOf(false) } var ytdlpVersion by remember { mutableStateOf( YoutubeDL.getInstance().version(context.applicationContext) ?: context.getString(R.string.ytdlp_update) ) } PreferenceItem( title = stringResource(id = R.string.ytdlp_update_action), description = ytdlpVersion, leadingIcon = { if (isUpdating) { CircularProgressIndicator( modifier = Modifier.padding(start = 8.dp, end = 16.dp) .size(24.dp) .padding(2.dp) ) } else { Icon( imageVector = Icons.Outlined.Update, contentDescription = null, modifier = Modifier.padding(start = 8.dp, end = 16.dp).size(24.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } }, onClick = { scope.launch(Dispatchers.IO) { runCatching { isUpdating = true UpdateUtil.updateYtDlp() ytdlpVersion = YT_DLP_VERSION.getString() } .onFailure { th -> th.printStackTrace() withContext(Dispatchers.Main) { context.makeToast( App.context.getString(R.string.yt_dlp_update_fail) ) } } .onSuccess { withContext(Dispatchers.Main) { context.makeToast( context.getString(R.string.yt_dlp_up_to_date) + " (${YT_DLP_VERSION.getString()})" ) } } isUpdating = false } }, onClickLabel = stringResource(id = R.string.update), trailingIcon = { IconButton(onClick = { showYtdlpDialog = true }) { Icon( imageVector = Icons.Outlined.Settings, contentDescription = stringResource(id = R.string.open_settings), ) } }, ) if (showYtdlpDialog) { YtdlpUpdateChannelDialog(onDismissRequest = { showYtdlpDialog = false }) } } item { PreferenceSubtitle(text = stringResource(R.string.network)) } item { PreferenceItem( title = stringResource(R.string.cookies), description = stringResource(R.string.cookies_desc), icon = Icons.Outlined.Cookie, onClick = { onNavigateTo(Route.COOKIE_PROFILE) }, ) } item { PreferenceSubtitle(text = stringResource(R.string.download_directory)) } item { var restrictFilenames by RESTRICT_FILENAMES.booleanState PreferenceSwitch( title = stringResource(id = R.string.restrict_filenames), icon = Icons.Outlined.Spellcheck, description = stringResource(id = R.string.restrict_filenames_desc), isChecked = restrictFilenames, ) { restrictFilenames = !restrictFilenames RESTRICT_FILENAMES.updateBoolean(restrictFilenames) } } } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/videolist/ExportImportDialog.kt ================================================ package com.junkfood.seal.ui.page.videolist import android.content.res.Configuration import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.DriveFileMove import androidx.compose.material.icons.outlined.Restore import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.junkfood.seal.R import com.junkfood.seal.database.backup.BackupUtil.BackupDestination import com.junkfood.seal.database.backup.BackupUtil.BackupType import com.junkfood.seal.ui.component.DialogSubtitle import com.junkfood.seal.ui.component.SealDialog import com.junkfood.seal.ui.component.SingleSelectChip import com.junkfood.seal.ui.theme.SealTheme @Composable fun ExportDialog( modifier: Modifier = Modifier, itemCount: Int = 0, onDismissRequest: () -> Unit = {}, onExport: (BackupType, BackupDestination) -> Unit, ) { var type by remember { mutableStateOf(BackupType.DownloadHistory) } var destination by remember { mutableStateOf(BackupDestination.File) } SealDialog( containerColor = MaterialTheme.colorScheme.surfaceContainerLow, modifier = modifier, onDismissRequest = onDismissRequest, confirmButton = { Button(onClick = { onExport(type, destination) }) { Text(text = stringResource(id = R.string.export_backup)) } }, dismissButton = { OutlinedButton(onClick = onDismissRequest) { Text(text = stringResource(id = R.string.cancel)) } }, title = { Text(text = stringResource(id = R.string.export_download_history)) }, icon = { Icon(imageVector = Icons.AutoMirrored.Outlined.DriveFileMove, contentDescription = null) }, text = { Column { Text( modifier = Modifier.padding(horizontal = 24.dp), text = stringResource(R.string.export_download_history_msg) .format( pluralStringResource(id = R.plurals.item_count, count = itemCount) .format(itemCount) ), ) DialogSubtitle( modifier = Modifier, text = stringResource(id = R.string.backup_type), ) LazyRow( contentPadding = PaddingValues(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { item { SingleSelectChip( selected = type == BackupType.DownloadHistory, onClick = { type = BackupType.DownloadHistory }, label = { Text(stringResource(id = R.string.full_backup)) }, ) } item { SingleSelectChip( selected = type == BackupType.URLList, onClick = { type = BackupType.URLList }, label = { Text(text = stringResource(id = R.string.video_url)) }, ) } } DialogSubtitle(modifier = Modifier, text = stringResource(id = R.string.export_to)) LazyRow( contentPadding = PaddingValues(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { item { SingleSelectChip( selected = destination == BackupDestination.File, onClick = { destination = BackupDestination.File }, label = { Text(stringResource(id = R.string.file)) }, ) } item { SingleSelectChip( selected = destination == BackupDestination.Clipboard, onClick = { destination = BackupDestination.Clipboard }, label = { Text(text = stringResource(id = R.string.clipboard)) }, ) } } } }, ) } @Composable fun ImportDialog( modifier: Modifier = Modifier, onDismissRequest: () -> Unit = {}, onImport: (BackupDestination) -> Unit, ) { var destination by remember { mutableStateOf(BackupDestination.File) } SealDialog( containerColor = MaterialTheme.colorScheme.surfaceContainerLow, modifier = modifier, onDismissRequest = onDismissRequest, confirmButton = { Button(onClick = { onImport(destination) }) { Text(text = stringResource(id = R.string.import_backup)) } }, dismissButton = { OutlinedButton(onClick = onDismissRequest) { Text(text = stringResource(id = R.string.cancel)) } }, title = { Text(text = stringResource(id = R.string.import_download_history)) }, icon = { Icon(imageVector = Icons.Outlined.Restore, contentDescription = null) }, text = { Column { Text( modifier = Modifier.padding(horizontal = 24.dp), text = stringResource(R.string.import_download_history_msg), ) DialogSubtitle( modifier = Modifier, text = stringResource(id = R.string.backup_type), ) LazyRow( contentPadding = PaddingValues(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { item { SingleSelectChip( selected = true, onClick = {}, label = { Text(stringResource(id = R.string.full_backup)) }, ) } } DialogSubtitle( modifier = Modifier, text = stringResource(id = R.string.import_from), ) LazyRow( contentPadding = PaddingValues(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { item { SingleSelectChip( selected = destination == BackupDestination.File, onClick = { destination = BackupDestination.File }, label = { Text(stringResource(id = R.string.file)) }, ) } item { SingleSelectChip( selected = destination == BackupDestination.Clipboard, onClick = { destination = BackupDestination.Clipboard }, label = { Text(text = stringResource(id = R.string.clipboard)) }, ) } } } }, ) } @Preview(locale = "ja") @Composable private fun PreviewExport() { SealTheme { ExportDialog() { _, _ -> } } } @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark") @Composable private fun PreviewImport() { SealTheme { ImportDialog() { _ -> } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/videolist/RemoveItemDialog.kt ================================================ package com.junkfood.seal.ui.page.videolist import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.junkfood.seal.R import com.junkfood.seal.database.objects.DownloadedVideoInfo import com.junkfood.seal.ui.component.CheckBoxItem import com.junkfood.seal.ui.component.SealDialog @Composable fun RemoveItemDialog( deleteFile: Boolean = false, onDeleteFileToggled: (Boolean) -> Unit = {}, info: DownloadedVideoInfo, onRemoveConfirm: (Boolean) -> Unit = {}, onDismissRequest: () -> Unit = {}, ) { SealDialog( onDismissRequest = onDismissRequest, title = { Text(text = stringResource(R.string.delete_info)) }, icon = { Icon(Icons.Outlined.Delete, null) }, text = { Column { Text( modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), text = stringResource(R.string.delete_info_msg).format(info.videoTitle), ) CheckBoxItem( modifier = Modifier.padding(horizontal = 12.dp), text = stringResource(R.string.delete_file), checked = deleteFile, onValueChange = onDeleteFileToggled, ) } }, confirmButton = { TextButton( onClick = { onDismissRequest() onRemoveConfirm(deleteFile) } ) { Text(text = stringResource(R.string.confirm)) } }, dismissButton = { TextButton(onClick = onDismissRequest) { Text(text = stringResource(R.string.dismiss)) } }, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/videolist/VideoDetailDrawer.kt ================================================ @file:OptIn(ExperimentalMaterialApi::class) package com.junkfood.seal.ui.page.videolist import android.content.Intent import android.content.res.Configuration import androidx.activity.compose.BackHandler import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ModalBottomSheetState import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.FileDownload import androidx.compose.material.icons.outlined.Link import androidx.compose.material.icons.outlined.Share import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.junkfood.seal.R import com.junkfood.seal.database.objects.DownloadedVideoInfo import com.junkfood.seal.ui.common.HapticFeedback.slightHapticFeedback import com.junkfood.seal.ui.component.FilledTonalButtonWithIcon import com.junkfood.seal.ui.component.LongTapTextButton import com.junkfood.seal.ui.component.OutlinedButtonWithIcon import com.junkfood.seal.ui.component.SealModalBottomSheetM2 import com.junkfood.seal.ui.theme.SealTheme import com.junkfood.seal.util.FileUtil import com.junkfood.seal.util.ToastUtil @Composable fun VideoDetailDrawer( sheetState: ModalBottomSheetState, info: DownloadedVideoInfo, isFileAvailable: Boolean = true, onDismissRequest: () -> Unit = {}, onDelete: () -> Unit = {}, ) { val uriHandler = LocalUriHandler.current val view = LocalView.current val context = LocalContext.current val hapticFeedback = LocalHapticFeedback.current BackHandler(sheetState.targetValue == ModalBottomSheetValue.Expanded) { onDismissRequest() } val onReDownload = remember(info) { { context.startActivity( Intent().apply { action = Intent.ACTION_SEND setPackage(context.packageName) type = "text/plain" putExtra(Intent.EXTRA_TEXT, info.videoUrl) } ) } } val shareTitle = stringResource(id = R.string.share) with(info) { VideoDetailDrawerImpl( sheetState = sheetState, title = videoTitle, author = videoAuthor, url = videoUrl, isFileAvailable = isFileAvailable, onReDownload = onReDownload, onDismissRequest = onDismissRequest, onDelete = { view.slightHapticFeedback() onDismissRequest() onDelete() }, onOpenLink = { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) onDismissRequest() uriHandler.openUri(videoUrl) }, onShareFile = { view.slightHapticFeedback() FileUtil.createIntentForSharingFile(videoPath)?.runCatching { context.startActivity(Intent.createChooser(this, shareTitle)) } }, ) } } @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun DrawerPreview() { SealTheme { VideoDetailDrawerImpl( sheetState = ModalBottomSheetState( ModalBottomSheetValue.Expanded, density = LocalDensity.current, ), onReDownload = {}, ) } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun VideoDetailDrawerImpl( sheetState: ModalBottomSheetState = ModalBottomSheetState(ModalBottomSheetValue.Hidden, density = LocalDensity.current), title: String = stringResource(id = R.string.video_title_sample_text), author: String = stringResource(id = R.string.video_creator_sample_text), url: String = "https://www.example.com", onDismissRequest: () -> Unit = {}, isFileAvailable: Boolean = true, onReDownload: (() -> Unit) = {}, onDelete: () -> Unit = {}, onOpenLink: () -> Unit = {}, onShareFile: () -> Unit = {}, ) { val clipboardManager = LocalClipboardManager.current val context = LocalContext.current SealModalBottomSheetM2( sheetState = sheetState, contentPadding = PaddingValues(horizontal = 20.dp), sheetContent = { Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)) { SelectionContainer { Text( modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), text = title, style = MaterialTheme.typography.titleLarge, ) } if (author != "playlist" && author != "null") SelectionContainer { Text( modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), text = author, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } Row(modifier = Modifier.padding(vertical = 6.dp).fillMaxWidth()) { LongTapTextButton( onClick = { clipboardManager.setText(AnnotatedString(url)) ToastUtil.makeToast(context.getString(R.string.link_copied)) }, onClickLabel = stringResource(id = R.string.copy_link), onLongClick = onOpenLink, onLongClickLabel = stringResource(R.string.open_url), ) { Icon(Icons.Outlined.Link, stringResource(R.string.video_url)) Text( modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), text = url, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } Row( modifier = Modifier.fillMaxWidth() .horizontalScroll(rememberScrollState()) .padding(top = 24.dp) .padding(horizontal = 8.dp), horizontalArrangement = Arrangement.End, ) { OutlinedButtonWithIcon( modifier = Modifier.padding(horizontal = 12.dp), onClick = onDelete, icon = Icons.Outlined.Delete, text = stringResource(R.string.remove), ) if (isFileAvailable) { FilledTonalButtonWithIcon( onClick = onShareFile, icon = Icons.Outlined.Share, text = stringResource(R.string.share), ) } else { FilledTonalButtonWithIcon( onClick = onReDownload, icon = Icons.Outlined.FileDownload, text = stringResource(R.string.redownload), colors = ButtonDefaults.filledTonalButtonColors( containerColor = MaterialTheme.colorScheme.tertiaryContainer, contentColor = MaterialTheme.colorScheme.onTertiaryContainer, ), ) } } }, ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/videolist/VideoListPage.kt ================================================ package com.junkfood.seal.ui.page.videolist import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.Image import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyGridItemSpanScope import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.DriveFileMove import androidx.compose.material.icons.outlined.DeleteSweep import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.Restore import androidx.compose.material.icons.outlined.Search import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.material3.BottomAppBar import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconToggleButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TriStateCheckbox import androidx.compose.material3.VerticalDivider import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.junkfood.seal.App import com.junkfood.seal.R import com.junkfood.seal.database.backup.BackupUtil import com.junkfood.seal.database.backup.BackupUtil.BackupDestination.Clipboard import com.junkfood.seal.database.backup.BackupUtil.BackupDestination.File import com.junkfood.seal.database.backup.BackupUtil.toJsonString import com.junkfood.seal.database.backup.BackupUtil.toURLListString import com.junkfood.seal.database.objects.DownloadedVideoInfo import com.junkfood.seal.ui.common.HapticFeedback.slightHapticFeedback import com.junkfood.seal.ui.common.LocalWindowWidthState import com.junkfood.seal.ui.component.BackButton import com.junkfood.seal.ui.component.CheckBoxItem import com.junkfood.seal.ui.component.ConfirmButton import com.junkfood.seal.ui.component.DismissButton import com.junkfood.seal.ui.component.MediaListItem import com.junkfood.seal.ui.component.SealDialog import com.junkfood.seal.ui.component.SealSearchBar import com.junkfood.seal.ui.component.VideoFilterChip import com.junkfood.seal.ui.svg.DynamicColorImageVectors import com.junkfood.seal.ui.svg.drawablevectors.videoSteaming import com.junkfood.seal.util.AUDIO_REGEX import com.junkfood.seal.util.FileUtil import com.junkfood.seal.util.ToastUtil import com.junkfood.seal.util.toFileSizeText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.androidx.compose.koinViewModel fun DownloadedVideoInfo.filterByType( videoFilter: Boolean = false, audioFilter: Boolean = true, ): Boolean { return if (!(videoFilter || audioFilter)) true else if (audioFilter) this.videoPath.contains(Regex(AUDIO_REGEX)) else !this.videoPath.contains(Regex(AUDIO_REGEX)) } fun DownloadedVideoInfo.filterSort( viewState: VideoListViewModel.VideoListViewState, filterSet: Set, ): Boolean { return filterByType(videoFilter = viewState.videoFilter, audioFilter = viewState.audioFilter) && filterByExtractor(filterSet.elementAtOrNull(viewState.activeFilterIndex)) } fun DownloadedVideoInfo.filterByExtractor(extractor: String?): Boolean { return extractor.isNullOrEmpty() || (this.extractor == extractor) } private const val TAG = "VideoListPage" @OptIn(ExperimentalMaterial3Api::class) @Composable fun VideoListPage(viewModel: VideoListViewModel = koinViewModel(), onNavigateBack: () -> Unit) { val viewState by viewModel.stateFlow.collectAsStateWithLifecycle() val fullVideoList by viewModel.videoListFlow.collectAsStateWithLifecycle(emptyList()) val searchedVideoList by viewModel.searchedVideoListFlow.collectAsStateWithLifecycle(emptyList()) val videoList = if (viewState.isSearching) searchedVideoList else fullVideoList val filterSet by viewModel.filterSetFlow.collectAsState(mutableSetOf()) val scrollBehavior = if (fullVideoList.isNotEmpty()) TopAppBarDefaults.exitUntilCollapsedScrollBehavior( rememberTopAppBarState(), canScroll = { true }, ) else TopAppBarDefaults.pinnedScrollBehavior() val scope = rememberCoroutineScope() val softKeyboardController = LocalSoftwareKeyboardController.current val view = LocalView.current val context = LocalContext.current val clipboardManager = LocalClipboardManager.current val fileSizeMap by viewModel.fileSizeMapFlow.collectAsStateWithLifecycle(initialValue = emptyMap()) val sheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val hostState = remember { SnackbarHostState() } var currentVideoInfo by remember { mutableStateOf(DownloadedVideoInfo()) } var isSelectEnabled by remember { mutableStateOf(false) } var showRemoveMultipleItemsDialog by remember { mutableStateOf(false) } var showExportDialog by remember { mutableStateOf(false) } var showImportDialog by remember { mutableStateOf(false) } val lazyListState = rememberLazyListState() @Composable fun FilterChips(modifier: Modifier = Modifier) { Row(modifier.horizontalScroll(rememberScrollState()).selectableGroup()) { Row(modifier = Modifier.padding(horizontal = 8.dp)) { VideoFilterChip( selected = viewState.audioFilter, onClick = { viewModel.clickAudioFilter() }, label = stringResource(id = R.string.audio), ) VideoFilterChip( selected = viewState.videoFilter, onClick = { viewModel.clickVideoFilter() }, label = stringResource(id = R.string.video), ) if (filterSet.size > 1) { VerticalDivider( modifier = Modifier.padding(horizontal = 6.dp) .height(24.dp) .width(1f.dp) .align(Alignment.CenterVertically), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), ) for (i in 0 until filterSet.size) { VideoFilterChip( selected = viewState.activeFilterIndex == i, onClick = { viewModel.clickExtractorFilter(i) }, label = filterSet.elementAt(i), ) } } } } } val selectedItemIds = remember(videoList, viewState) { mutableStateListOf() } LaunchedEffect(isSelectEnabled) { if (!isSelectEnabled) { delay(200) selectedItemIds.clear() } } val selectedVideoCount = remember(selectedItemIds.size) { mutableIntStateOf( videoList.count { info -> selectedItemIds.contains(info.id) && info.filterByType(videoFilter = true, audioFilter = false) } ) } val selectedAudioCount = remember(selectedItemIds.size) { mutableIntStateOf( videoList.count { info -> selectedItemIds.contains(info.id) && info.filterByType(videoFilter = false, audioFilter = true) } ) } val selectedFileSizeSum by remember(selectedItemIds.size) { derivedStateOf { selectedItemIds.fold(0L) { acc: Long, id: Int -> acc + fileSizeMap.getOrElse(id) { 0L } } } } val visibleItemCount = remember(videoList, viewState) { mutableIntStateOf(videoList.count { it.filterSort(viewState, filterSet) }) } val checkBoxState by remember(selectedItemIds, visibleItemCount) { derivedStateOf { if (selectedItemIds.isEmpty()) ToggleableState.Off else if ( selectedItemIds.size == visibleItemCount.intValue && selectedItemIds.isNotEmpty() ) ToggleableState.On else ToggleableState.Indeterminate } } var showRemoveDialog by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) } BackHandler(isSelectEnabled || viewState.isSearching) { if (isSelectEnabled) { isSelectEnabled = false } else { viewModel.toggleSearch(false) } } LaunchedEffect(sheetState.targetValue, isSelectEnabled) { if (showBottomSheet || isSelectEnabled) { softKeyboardController?.hide() } } Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { LargeTopAppBar( title = { Text(modifier = Modifier, text = stringResource(R.string.downloads_history)) }, navigationIcon = { BackButton { onNavigateBack() } }, actions = { Row { if (fullVideoList.isNotEmpty()) { IconToggleButton( modifier = Modifier, onCheckedChange = { view.slightHapticFeedback() viewModel.toggleSearch(it) if (it) { scope.launch { delay(50) lazyListState.animateScrollToItem(0) } } }, checked = viewState.isSearching, ) { Icon( imageVector = Icons.Outlined.Search, contentDescription = stringResource(R.string.search), ) } } var expanded by remember { mutableStateOf(false) } Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) { IconButton(onClick = { expanded = true }) { Icon( imageVector = Icons.Outlined.MoreVert, contentDescription = stringResource(id = R.string.show_more_actions), ) } DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, ) { if (visibleItemCount.intValue > 0) { DropdownMenuItem( leadingIcon = { Icon( imageVector = Icons.AutoMirrored.Outlined.DriveFileMove, contentDescription = null, ) }, text = { Text(text = stringResource(id = R.string.export_backup)) }, onClick = { showExportDialog = true expanded = false }, ) } DropdownMenuItem( leadingIcon = { Icon( imageVector = Icons.Outlined.Restore, contentDescription = null, ) }, text = { Text(text = stringResource(id = R.string.import_backup)) }, onClick = { showImportDialog = true expanded = false }, ) } } } }, scrollBehavior = scrollBehavior, ) }, bottomBar = { AnimatedVisibility( isSelectEnabled, enter = expandVertically(), exit = shrinkVertically(), ) { BottomAppBar(modifier = Modifier) { val selectAllText = stringResource(R.string.select_all) TriStateCheckbox( modifier = Modifier.semantics { this.contentDescription = selectAllText }, state = checkBoxState, onClick = { view.slightHapticFeedback() when (checkBoxState) { ToggleableState.On -> selectedItemIds.clear() else -> { for (item in videoList) { if ( !selectedItemIds.contains(item.id) && item.filterSort(viewState, filterSet) ) { selectedItemIds.add(item.id) } } } } }, ) Text( modifier = Modifier.weight(1f), text = stringResource(R.string.multiselect_item_count) .format(selectedVideoCount.intValue, selectedAudioCount.intValue), style = MaterialTheme.typography.labelLarge, ) IconButton( onClick = { view.slightHapticFeedback() showRemoveMultipleItemsDialog = true }, enabled = selectedItemIds.isNotEmpty(), ) { Icon( imageVector = Icons.Outlined.DeleteSweep, contentDescription = stringResource(id = R.string.remove), ) } } } }, snackbarHost = { SnackbarHost(hostState = hostState) }, ) { innerPadding -> if (fullVideoList.isEmpty()) Box(modifier = Modifier.fillMaxSize()) { val painter = rememberVectorPainter(image = DynamicColorImageVectors.videoSteaming()) Column( modifier = Modifier.align(Alignment.Center).widthIn(max = 360.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Image( painter = painter, contentDescription = null, modifier = Modifier.padding(vertical = 20.dp) .fillMaxWidth(0.5f) .widthIn(max = 240.dp), ) Text( text = stringResource(R.string.no_downloaded_media), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } val cellCount = when (LocalWindowWidthState.current) { WindowWidthSizeClass.Expanded -> 2 else -> 1 } val span: (LazyGridItemSpanScope) -> GridItemSpan = { GridItemSpan(cellCount) } LazyColumn(modifier = Modifier, state = lazyListState, contentPadding = innerPadding) { if (fullVideoList.isNotEmpty()) { item { Column { AnimatedVisibility(visible = viewState.isSearching) { SealSearchBar( modifier = Modifier.padding(horizontal = 12.dp).padding(vertical = 8.dp), text = viewState.searchText, placeholderText = stringResource(R.string.search_in_downloads), onValueChange = viewModel::updateSearchText, ) } FilterChips(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) } } } for (info in videoList) { item(key = info.id, contentType = { info.videoPath.contains(AUDIO_REGEX) }) { with(info) { AnimatedVisibility( modifier = Modifier, visible = info.filterSort(viewState, filterSet), exit = shrinkVertically() + fadeOut(), enter = expandVertically() + fadeIn(), ) { MediaListItem( modifier = Modifier, title = videoTitle, author = videoAuthor, thumbnailUrl = thumbnailUrl, videoPath = videoPath, videoFileSize = fileSizeMap.getOrElse(id) { 0L }, videoUrl = videoUrl, isSelectEnabled = { isSelectEnabled }, isSelected = { selectedItemIds.contains(id) }, onSelect = { if (selectedItemIds.contains(id)) selectedItemIds.remove(id) else selectedItemIds.add(id) }, onClick = { FileUtil.openFile(path = videoPath) { ToastUtil.makeToastSuspend( App.context.getString(R.string.file_unavailable) ) } }, onLongClick = { isSelectEnabled = true selectedItemIds.add(id) }, onShowContextMenu = { view.slightHapticFeedback() currentVideoInfo = info scope.launch { showBottomSheet = true delay(50) sheetState.show() } }, ) } } } } } } if (showBottomSheet) { val isFileAvailable = fileSizeMap[currentVideoInfo.id] != 0L VideoDetailDrawer( sheetState = sheetState, info = currentVideoInfo, isFileAvailable = isFileAvailable, onDismissRequest = { scope.launch { sheetState.hide() }.invokeOnCompletion { showBottomSheet = false } }, onDelete = { showRemoveDialog = true }, ) } var deleteFile by remember { mutableStateOf(false) } if (showRemoveDialog) { RemoveItemDialog( info = currentVideoInfo, deleteFile = deleteFile, onDeleteFileToggled = { deleteFile = it }, onRemoveConfirm = { viewModel.deleteDownloadHistory(listOf(currentVideoInfo), deleteFile = deleteFile) }, onDismissRequest = { showRemoveDialog = false }, ) } if (showRemoveMultipleItemsDialog) { SealDialog( onDismissRequest = { showRemoveMultipleItemsDialog = false }, icon = { Icon(Icons.Outlined.DeleteSweep, null) }, title = { Text(stringResource(R.string.delete_info)) }, text = { Column { Text( modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), text = stringResource(R.string.delete_multiple_items_msg) .format(selectedItemIds.size), ) CheckBoxItem( modifier = Modifier.padding(horizontal = 12.dp), text = stringResource(R.string.delete_file) + " (${selectedFileSizeSum.toFileSizeText()})", checked = deleteFile, ) { deleteFile = !deleteFile } } }, confirmButton = { ConfirmButton { viewModel.deleteDownloadHistory( infoList = videoList.filter { selectedItemIds.contains(it.id) }, deleteFile = deleteFile, ) showRemoveMultipleItemsDialog = false isSelectEnabled = false } }, dismissButton = { DismissButton { showRemoveMultipleItemsDialog = false } }, ) } var backupString by remember { mutableStateOf("") } val exportLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument("text/plain") ) { uri -> uri?.let { scope.launch(Dispatchers.IO) { context.contentResolver.openOutputStream(uri)?.use { it.write(backupString.toByteArray()) } withContext(Dispatchers.Main) { showExportDialog = false } } } } if (showExportDialog) { val list = if (selectedItemIds.isNotEmpty()) { videoList.filter { selectedItemIds.contains(it.id) } } else { videoList.filter { it.filterSort(viewState, filterSet) } } ExportDialog(onDismissRequest = { showExportDialog = false }, itemCount = list.size) { type, destination -> list.backupToString(type).let { when (destination) { Clipboard -> clipboardManager.setText(AnnotatedString(it)) File -> { backupString = it exportLauncher.launch( BackupUtil.getDownloadHistoryExportFilename(context = context) ) } } view.slightHapticFeedback() showExportDialog = false } } } val importLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { uri -> uri?.let { viewModel.importBackupFromUri(context, uri) { viewModel.showImportedSnackbar(hostState, context, it) } } } if (showImportDialog) { ImportDialog(onDismissRequest = { showImportDialog = false }) { destination -> scope.launch { when (destination) { Clipboard -> { clipboardManager.getText()?.text?.let { str -> viewModel.importBackupFromText(str) { viewModel.showImportedSnackbar(hostState, context, it) } } } File -> { importLauncher.launch("text/plain") } } } view.slightHapticFeedback() showImportDialog = false } } } private fun List.backupToString(type: BackupUtil.BackupType): String { return when (type) { BackupUtil.BackupType.DownloadHistory -> reversed().toJsonString() BackupUtil.BackupType.URLList -> toURLListString() else -> throw IllegalArgumentException() } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/page/videolist/VideoListViewModel.kt ================================================ package com.junkfood.seal.ui.page.videolist import android.content.Context import android.net.Uri import androidx.compose.material3.SnackbarHostState import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.junkfood.seal.R import com.junkfood.seal.database.backup.BackupUtil import com.junkfood.seal.database.backup.BackupUtil.decodeToBackup import com.junkfood.seal.database.objects.DownloadedVideoInfo import com.junkfood.seal.util.DatabaseUtil import com.junkfood.seal.util.FileUtil.getFileSize import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext private const val TAG = "VideoListViewModel" class VideoListViewModel : ViewModel() { private val mutableStateFlow = MutableStateFlow(VideoListViewState()) val stateFlow = mutableStateFlow.asStateFlow() private val viewState get() = stateFlow.value private val _mediaInfoFlow = DatabaseUtil.getDownloadHistoryFlow() val videoListFlow: Flow> = _mediaInfoFlow.map { it.reversed().sortedBy { info -> info.filterByType() } } val searchedVideoListFlow = videoListFlow.combine(stateFlow) { list, state -> if (!state.isSearching || state.searchText.isBlank()) list else list.filter { state.searchText.let { text -> with(it) { videoTitle.contains(text, ignoreCase = true) || videoAuthor.contains(text, ignoreCase = true) || extractor.contains(text, ignoreCase = true) || videoPath.contains(text, ignoreCase = true) } } } } val filterSetFlow = searchedVideoListFlow.map { infoList -> mutableSetOf().apply { infoList.forEach { this.add(it.extractor) } } } val fileSizeMapFlow = videoListFlow.flowOn(Dispatchers.IO).map { list -> list.associate { it.id to it.videoPath.getFileSize() } } fun clickVideoFilter() { if (mutableStateFlow.value.videoFilter) mutableStateFlow.update { it.copy(videoFilter = false) } else mutableStateFlow.update { it.copy(videoFilter = true, audioFilter = false) } } fun clickAudioFilter() { if (mutableStateFlow.value.audioFilter) mutableStateFlow.update { it.copy(audioFilter = false) } else mutableStateFlow.update { it.copy(audioFilter = true, videoFilter = false) } } fun clickExtractorFilter(index: Int) { if (mutableStateFlow.value.activeFilterIndex == index) mutableStateFlow.update { it.copy(activeFilterIndex = -1) } else mutableStateFlow.update { it.copy(activeFilterIndex = index) } } fun toggleSearch(isSearching: Boolean = !viewState.isSearching) { mutableStateFlow.update { it.copy(isSearching = isSearching, searchText = "") } } fun updateSearchText(text: String) { mutableStateFlow.update { it.copy(searchText = text) } } fun deleteDownloadHistory(infoList: List, deleteFile: Boolean) { viewModelScope.launch(Dispatchers.IO) { DatabaseUtil.deleteInfoList(infoList = infoList, deleteFile = deleteFile) } } fun importBackupFromUri(context: Context, uri: Uri, onComplete: suspend (Int) -> Unit) { viewModelScope.launch(Dispatchers.IO) { var res = 0 context.contentResolver.openInputStream(uri)?.use { input -> input.bufferedReader(Charsets.UTF_8).readText().let { res = importBackupFromText(it) } } withContext(Dispatchers.Main) { onComplete(res) } } } fun importBackupFromText(string: String, onComplete: suspend (Int) -> Unit) { viewModelScope.launch(Dispatchers.IO) { val res = importBackupFromText(string) withContext(Dispatchers.Main) { onComplete(res) } } } private suspend fun importBackupFromText(string: String): Int { string.decodeToBackup().onSuccess { return DatabaseUtil.importBackup( backup = it, types = setOf(BackupUtil.BackupType.DownloadHistory), ) } return 0 } fun showImportedSnackbar(hostState: SnackbarHostState, context: Context, importedCount: Int) { viewModelScope.launch(Dispatchers.Main) { hostState.showSnackbar( message = context .getString(R.string.download_history_imported) .format( context.resources .getQuantityString(R.plurals.item_count, importedCount) .format(importedCount) ) ) } } data class VideoListViewState( val activeFilterIndex: Int = -1, val videoFilter: Boolean = false, val audioFilter: Boolean = false, val isSearching: Boolean = false, val searchText: String = "", ) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/svg/VectorPreviews.kt ================================================ package com.junkfood.seal.ui.svg import android.content.res.Configuration import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import com.junkfood.seal.ui.svg.drawablevectors.coder import com.junkfood.seal.ui.svg.drawablevectors.download import com.junkfood.seal.ui.svg.drawablevectors.videoFiles import com.junkfood.seal.ui.svg.drawablevectors.videoSteaming import com.junkfood.seal.ui.theme.SealTheme @Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(name = "Night", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun Download() { SealTheme { Surface { Column { Image( imageVector = DynamicColorImageVectors.download(), contentDescription = null, modifier = Modifier.aspectRatio(16 / 9f), ) Image( imageVector = DynamicColorImageVectors.coder(), contentDescription = null, modifier = Modifier.aspectRatio(16 / 9f), ) Image( imageVector = DynamicColorImageVectors.videoFiles(), contentDescription = null, modifier = Modifier.aspectRatio(16 / 9f), ) Image( imageVector = DynamicColorImageVectors.videoSteaming(), contentDescription = null, modifier = Modifier.aspectRatio(16 / 9f), ) } } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/svg/__DrawableVectors.kt ================================================ package com.junkfood.seal.ui.svg public object DynamicColorImageVectors ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/svg/drawablevectors/Coder.kt ================================================ package com.junkfood.seal.ui.svg.drawablevectors import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType.Companion.NonZero import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap.Companion.Butt import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp import com.junkfood.seal.ui.svg.DynamicColorImageVectors import com.junkfood.seal.ui.theme.FixedAccentColors @Composable fun DynamicColorImageVectors.coder(): ImageVector { return Builder( name = "Coder", defaultWidth = 717.67004.dp, defaultHeight = 453.96432.dp, viewportWidth = 717.67004f, viewportHeight = 453.96432f, ) .apply { path( fill = SolidColor(Color(0xFF2f2e41)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(271.986f, 71.477f) arcToRelative(3.827f, 3.827f, 0.0f, false, true, -4.314f, 1.257f) arcToRelative(5.829f, 5.829f, 0.0f, false, true, -0.679f, -0.248f) arcToRelative(28.826f, 28.826f, 0.0f, false, false, -0.367f, -7.989f) arcToRelative(21.362f, 21.362f, 0.0f, false, true, -1.64f, 6.57f) arcToRelative(8.911f, 8.911f, 0.0f, false, true, -1.76f, -2.513f) arcToRelative(22.425f, 22.425f, 0.0f, false, true, -1.603f, -6.046f) curveToRelative(-1.204f, -7.088f, -2.393f, -14.462f, -0.428f, -21.389f) curveToRelative(2.879f, -10.119f, 11.957f, -17.393f, 15.749f, -27.211f) lineToRelative(1.868f, -1.677f) curveToRelative(3.198f, 5.445f, 3.339f, 12.212f, 2.036f, 18.387f) curveToRelative(-1.297f, 6.187f, -3.909f, 11.994f, -5.975f, 17.96f) reflectiveCurveToRelative(-3.613f, 12.313f, -2.655f, 18.556f) curveTo(272.439f, 68.604f, 272.775f, 70.22f, 271.986f, 71.477f) close() } path( fill = SolidColor(Color(0xFFffb6b6)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(230.469f, 157.098f) lineToRelative(-12.174f, 55.681f) lineToRelative(53.174f, 17.319f) lineToRelative(-7.819f, -73.0f) lineToRelative(-33.181f, 0.0f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(605.699f, 452.598f) horizontalLineToRelative(24.096f) lineTo(629.795f, 342.129f) arcTo(107.653f, 107.653f, 0.0f, false, false, 522.264f, 234.598f) lineTo(298.326f, 234.598f) arcTo(107.653f, 107.653f, 0.0f, false, false, 190.795f, 342.129f) lineTo(190.795f, 452.598f) horizontalLineToRelative(24.096f) lineTo(214.89f, 377.129f) arcTo(108.654f, 108.654f, 0.0f, false, true, 323.421f, 268.598f) lineTo(497.168f, 268.598f) arcTo(108.654f, 108.654f, 0.0f, false, true, 605.699f, 377.129f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainerHighest), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(312.894f, 394.806f) lineToRelative(-25.68f, -7.775f) arcToRelative(88.484f, 88.484f, 0.0f, false, true, -2.509f, -20.963f) arcToRelative(8.399f, 8.399f, 0.0f, false, true, 13.815f, -6.355f) curveToRelative(13.773f, 11.814f, 33.948f, 18.118f, 46.968f, 31.58f) arcToRelative(52.073f, 52.073f, 0.0f, false, true, 13.888f, 42.946f) lineToRelative(5.621f, 18.181f) arcToRelative(87.259f, 87.259f, 0.0f, false, true, -63.977f, -35.285f) arcToRelative(84.287f, 84.287f, 0.0f, false, true, -10.122f, -18.565f) curveTo(301.79f, 397.335f, 312.894f, 394.806f, 312.894f, 394.806f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainerHighest), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(379.823f, 424.579f) lineToRelative(13.098f, -3.966f) quadToRelative(0.025f, -0.104f, 0.05f, -0.209f) arcToRelative(8.409f, 8.409f, 0.0f, false, false, -12.483f, -9.094f) curveToRelative(-5.946f, 3.532f, -12.548f, 6.573f, -17.289f, 11.476f) arcToRelative(26.559f, 26.559f, 0.0f, false, false, -7.083f, 21.904f) lineToRelative(-2.867f, 9.273f) arcToRelative(44.505f, 44.505f, 0.0f, false, false, 32.63f, -17.996f) arcToRelative(42.989f, 42.989f, 0.0f, false, false, 5.162f, -9.469f) curveTo(385.487f, 425.869f, 379.823f, 424.579f, 379.823f, 424.579f) close() } path( fill = SolidColor(Color(0xFF3f3d56)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(485.849f, 153.13f) lineTo(434.843f, 153.13f) arcToRelative(2.721f, 2.721f, 0.0f, false, false, -2.717f, 2.724f) verticalLineToRelative(80.574f) horizontalLineToRelative(56.447f) lineTo(488.573f, 155.854f) arcTo(2.723f, 2.723f, 0.0f, false, false, 485.849f, 153.13f) close() moveTo(460.517f, 198.911f) arcToRelative(6.051f, 6.051f, 0.0f, false, true, -6.023f, -6.023f) verticalLineToRelative(-9.293f) arcToRelative(6.023f, 6.023f, 0.0f, false, true, 12.046f, 0.0f) verticalLineToRelative(9.293f) arcToRelative(6.051f, 6.051f, 0.0f, false, true, -6.023f, 6.023f) close() } path( fill = SolidColor(Color(0xFF3f3d56)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(570.309f, 27.385f) lineTo(352.28f, 27.385f) arcToRelative(7.078f, 7.078f, 0.0f, false, false, -7.066f, 7.066f) lineTo(345.214f, 181.579f) arcToRelative(7.073f, 7.073f, 0.0f, false, false, 7.066f, 7.066f) horizontalLineToRelative(218.029f) arcToRelative(7.073f, 7.073f, 0.0f, false, false, 7.066f, -7.066f) lineTo(577.376f, 34.452f) arcTo(7.078f, 7.078f, 0.0f, false, false, 570.309f, 27.385f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surface), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(568.489f, 32.489f) lineTo(354.102f, 32.489f) arcToRelative(3.787f, 3.787f, 0.0f, false, false, -3.779f, 3.787f) lineTo(350.323f, 179.756f) arcToRelative(3.786f, 3.786f, 0.0f, false, false, 3.779f, 3.779f) lineTo(568.489f, 183.536f) arcToRelative(3.786f, 3.786f, 0.0f, false, false, 3.779f, -3.779f) lineTo(572.268f, 36.276f) arcTo(3.787f, 3.787f, 0.0f, false, false, 568.489f, 32.489f) close() } path( fill = SolidColor(Color(0xFF3f3d56)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(431.951f, 233.851f) verticalLineToRelative(7.774f) arcToRelative(1.523f, 1.523f, 0.0f, false, false, 1.52f, 1.52f) horizontalLineToRelative(53.758f) arcToRelative(1.527f, 1.527f, 0.0f, false, false, 1.52f, -1.52f) lineTo(488.748f, 233.851f) close() } path( fill = SolidColor(Color(0xFF3f3d56)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(526.73f, 245.849f) lineTo(399.181f, 245.849f) arcToRelative(2.346f, 2.346f, 0.0f, false, true, -2.296f, -2.83f) lineToRelative(1.979f, -9.4f) arcToRelative(2.356f, 2.356f, 0.0f, false, true, 2.296f, -1.863f) lineTo(524.752f, 231.757f) arcToRelative(2.356f, 2.356f, 0.0f, false, true, 2.296f, 1.863f) lineToRelative(1.979f, 9.4f) arcToRelative(2.346f, 2.346f, 0.0f, false, true, -2.296f, 2.83f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(402.554f, 233.45f) lineTo(405.506f, 233.45f) arcTo(0.488f, 0.488f, 0.0f, false, true, 405.994f, 233.938f) lineTo(405.994f, 235.318f) arcTo(0.488f, 0.488f, 0.0f, false, true, 405.506f, 235.807f) lineTo(402.554f, 235.807f) arcTo(0.488f, 0.488f, 0.0f, false, true, 402.066f, 235.318f) lineTo(402.066f, 233.938f) arcTo(0.488f, 0.488f, 0.0f, false, true, 402.554f, 233.45f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(410.411f, 233.45f) lineTo(413.363f, 233.45f) arcTo(0.488f, 0.488f, 0.0f, false, true, 413.851f, 233.938f) lineTo(413.851f, 235.318f) arcTo(0.488f, 0.488f, 0.0f, false, true, 413.363f, 235.807f) lineTo(410.411f, 235.807f) arcTo(0.488f, 0.488f, 0.0f, false, true, 409.923f, 235.318f) lineTo(409.923f, 233.938f) arcTo(0.488f, 0.488f, 0.0f, false, true, 410.411f, 233.45f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(418.268f, 233.45f) lineTo(421.22f, 233.45f) arcTo(0.488f, 0.488f, 0.0f, false, true, 421.708f, 233.938f) lineTo(421.708f, 235.318f) arcTo(0.488f, 0.488f, 0.0f, false, true, 421.22f, 235.807f) lineTo(418.268f, 235.807f) arcTo(0.488f, 0.488f, 0.0f, false, true, 417.779f, 235.318f) lineTo(417.779f, 233.938f) arcTo(0.488f, 0.488f, 0.0f, false, true, 418.268f, 233.45f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(426.124f, 233.45f) lineTo(429.076f, 233.45f) arcTo(0.488f, 0.488f, 0.0f, false, true, 429.565f, 233.938f) lineTo(429.565f, 235.318f) arcTo(0.488f, 0.488f, 0.0f, false, true, 429.076f, 235.807f) lineTo(426.124f, 235.807f) arcTo(0.488f, 0.488f, 0.0f, false, true, 425.636f, 235.318f) lineTo(425.636f, 233.938f) arcTo(0.488f, 0.488f, 0.0f, false, true, 426.124f, 233.45f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(433.981f, 233.45f) lineTo(436.933f, 233.45f) arcTo(0.488f, 0.488f, 0.0f, false, true, 437.421f, 233.938f) lineTo(437.421f, 235.318f) arcTo(0.488f, 0.488f, 0.0f, false, true, 436.933f, 235.807f) lineTo(433.981f, 235.807f) arcTo(0.488f, 0.488f, 0.0f, false, true, 433.493f, 235.318f) lineTo(433.493f, 233.938f) arcTo(0.488f, 0.488f, 0.0f, false, true, 433.981f, 233.45f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(441.838f, 233.45f) lineTo(444.79f, 233.45f) arcTo(0.488f, 0.488f, 0.0f, false, true, 445.278f, 233.938f) lineTo(445.278f, 235.318f) arcTo(0.488f, 0.488f, 0.0f, false, true, 444.79f, 235.807f) lineTo(441.838f, 235.807f) arcTo(0.488f, 0.488f, 0.0f, false, true, 441.35f, 235.318f) lineTo(441.35f, 233.938f) arcTo(0.488f, 0.488f, 0.0f, false, true, 441.838f, 233.45f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(449.695f, 233.45f) lineTo(452.647f, 233.45f) arcTo(0.488f, 0.488f, 0.0f, false, true, 453.135f, 233.938f) lineTo(453.135f, 235.318f) arcTo(0.488f, 0.488f, 0.0f, false, true, 452.647f, 235.807f) lineTo(449.695f, 235.807f) arcTo(0.488f, 0.488f, 0.0f, false, true, 449.207f, 235.318f) lineTo(449.207f, 233.938f) arcTo(0.488f, 0.488f, 0.0f, false, true, 449.695f, 233.45f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(457.551f, 233.45f) lineTo(460.503f, 233.45f) arcTo(0.488f, 0.488f, 0.0f, false, true, 460.992f, 233.938f) lineTo(460.992f, 235.318f) arcTo(0.488f, 0.488f, 0.0f, false, true, 460.503f, 235.807f) lineTo(457.551f, 235.807f) arcTo(0.488f, 0.488f, 0.0f, false, true, 457.063f, 235.318f) lineTo(457.063f, 233.938f) arcTo(0.488f, 0.488f, 0.0f, false, true, 457.551f, 233.45f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(465.408f, 233.45f) lineTo(468.36f, 233.45f) arcTo(0.488f, 0.488f, 0.0f, false, true, 468.848f, 233.938f) lineTo(468.848f, 235.318f) arcTo(0.488f, 0.488f, 0.0f, false, true, 468.36f, 235.807f) lineTo(465.408f, 235.807f) arcTo(0.488f, 0.488f, 0.0f, false, true, 464.92f, 235.318f) lineTo(464.92f, 233.938f) arcTo(0.488f, 0.488f, 0.0f, false, true, 465.408f, 233.45f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(473.265f, 233.45f) lineTo(476.217f, 233.45f) arcTo(0.488f, 0.488f, 0.0f, false, true, 476.705f, 233.938f) lineTo(476.705f, 235.318f) arcTo(0.488f, 0.488f, 0.0f, false, true, 476.217f, 235.807f) lineTo(473.265f, 235.807f) arcTo(0.488f, 0.488f, 0.0f, false, true, 472.777f, 235.318f) lineTo(472.777f, 233.938f) arcTo(0.488f, 0.488f, 0.0f, false, true, 473.265f, 233.45f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(481.122f, 233.45f) lineTo(484.074f, 233.45f) arcTo(0.488f, 0.488f, 0.0f, false, true, 484.562f, 233.938f) lineTo(484.562f, 235.318f) arcTo(0.488f, 0.488f, 0.0f, false, true, 484.074f, 235.807f) lineTo(481.122f, 235.807f) arcTo(0.488f, 0.488f, 0.0f, false, true, 480.634f, 235.318f) lineTo(480.634f, 233.938f) arcTo(0.488f, 0.488f, 0.0f, false, true, 481.122f, 233.45f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(488.979f, 233.45f) lineTo(491.931f, 233.45f) arcTo(0.488f, 0.488f, 0.0f, false, true, 492.419f, 233.938f) lineTo(492.419f, 235.318f) arcTo(0.488f, 0.488f, 0.0f, false, true, 491.931f, 235.807f) lineTo(488.979f, 235.807f) arcTo(0.488f, 0.488f, 0.0f, false, true, 488.49f, 235.318f) lineTo(488.49f, 233.938f) arcTo(0.488f, 0.488f, 0.0f, false, true, 488.979f, 233.45f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(496.835f, 233.45f) lineTo(499.787f, 233.45f) arcTo(0.488f, 0.488f, 0.0f, false, true, 500.275f, 233.938f) lineTo(500.275f, 235.318f) arcTo(0.488f, 0.488f, 0.0f, false, true, 499.787f, 235.807f) lineTo(496.835f, 235.807f) arcTo(0.488f, 0.488f, 0.0f, false, true, 496.347f, 235.318f) lineTo(496.347f, 233.938f) arcTo(0.488f, 0.488f, 0.0f, false, true, 496.835f, 233.45f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(504.692f, 233.45f) lineTo(507.644f, 233.45f) arcTo(0.488f, 0.488f, 0.0f, false, true, 508.132f, 233.938f) lineTo(508.132f, 235.318f) arcTo(0.488f, 0.488f, 0.0f, false, true, 507.644f, 235.807f) lineTo(504.692f, 235.807f) arcTo(0.488f, 0.488f, 0.0f, false, true, 504.204f, 235.318f) lineTo(504.204f, 233.938f) arcTo(0.488f, 0.488f, 0.0f, false, true, 504.692f, 233.45f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(512.549f, 233.45f) lineTo(515.501f, 233.45f) arcTo(0.488f, 0.488f, 0.0f, false, true, 515.989f, 233.938f) lineTo(515.989f, 235.318f) arcTo(0.488f, 0.488f, 0.0f, false, true, 515.501f, 235.807f) lineTo(512.549f, 235.807f) arcTo(0.488f, 0.488f, 0.0f, false, true, 512.061f, 235.318f) lineTo(512.061f, 233.938f) arcTo(0.488f, 0.488f, 0.0f, false, true, 512.549f, 233.45f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(520.406f, 233.45f) lineTo(523.358f, 233.45f) arcTo(0.488f, 0.488f, 0.0f, false, true, 523.846f, 233.938f) lineTo(523.846f, 235.318f) arcTo(0.488f, 0.488f, 0.0f, false, true, 523.358f, 235.807f) lineTo(520.406f, 235.807f) arcTo(0.488f, 0.488f, 0.0f, false, true, 519.917f, 235.318f) lineTo(519.917f, 233.938f) arcTo(0.488f, 0.488f, 0.0f, false, true, 520.406f, 233.45f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(402.461f, 237.378f) lineTo(405.413f, 237.378f) arcTo(0.488f, 0.488f, 0.0f, false, true, 405.901f, 237.866f) lineTo(405.901f, 239.247f) arcTo(0.488f, 0.488f, 0.0f, false, true, 405.413f, 239.735f) lineTo(402.461f, 239.735f) arcTo(0.488f, 0.488f, 0.0f, false, true, 401.973f, 239.247f) lineTo(401.973f, 237.866f) arcTo(0.488f, 0.488f, 0.0f, false, true, 402.461f, 237.378f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(410.318f, 237.378f) lineTo(413.27f, 237.378f) arcTo(0.488f, 0.488f, 0.0f, false, true, 413.758f, 237.866f) lineTo(413.758f, 239.247f) arcTo(0.488f, 0.488f, 0.0f, false, true, 413.27f, 239.735f) lineTo(410.318f, 239.735f) arcTo(0.488f, 0.488f, 0.0f, false, true, 409.83f, 239.247f) lineTo(409.83f, 237.866f) arcTo(0.488f, 0.488f, 0.0f, false, true, 410.318f, 237.378f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(418.175f, 237.378f) lineTo(421.127f, 237.378f) arcTo(0.488f, 0.488f, 0.0f, false, true, 421.615f, 237.866f) lineTo(421.615f, 239.247f) arcTo(0.488f, 0.488f, 0.0f, false, true, 421.127f, 239.735f) lineTo(418.175f, 239.735f) arcTo(0.488f, 0.488f, 0.0f, false, true, 417.687f, 239.247f) lineTo(417.687f, 237.866f) arcTo(0.488f, 0.488f, 0.0f, false, true, 418.175f, 237.378f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(426.032f, 237.378f) lineTo(428.984f, 237.378f) arcTo(0.488f, 0.488f, 0.0f, false, true, 429.472f, 237.866f) lineTo(429.472f, 239.247f) arcTo(0.488f, 0.488f, 0.0f, false, true, 428.984f, 239.735f) lineTo(426.032f, 239.735f) arcTo(0.488f, 0.488f, 0.0f, false, true, 425.543f, 239.247f) lineTo(425.543f, 237.866f) arcTo(0.488f, 0.488f, 0.0f, false, true, 426.032f, 237.378f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(433.888f, 237.378f) lineTo(436.84f, 237.378f) arcTo(0.488f, 0.488f, 0.0f, false, true, 437.329f, 237.866f) lineTo(437.329f, 239.247f) arcTo(0.488f, 0.488f, 0.0f, false, true, 436.84f, 239.735f) lineTo(433.888f, 239.735f) arcTo(0.488f, 0.488f, 0.0f, false, true, 433.4f, 239.247f) lineTo(433.4f, 237.866f) arcTo(0.488f, 0.488f, 0.0f, false, true, 433.888f, 237.378f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(441.745f, 237.378f) lineTo(444.697f, 237.378f) arcTo(0.488f, 0.488f, 0.0f, false, true, 445.185f, 237.866f) lineTo(445.185f, 239.247f) arcTo(0.488f, 0.488f, 0.0f, false, true, 444.697f, 239.735f) lineTo(441.745f, 239.735f) arcTo(0.488f, 0.488f, 0.0f, false, true, 441.257f, 239.247f) lineTo(441.257f, 237.866f) arcTo(0.488f, 0.488f, 0.0f, false, true, 441.745f, 237.378f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(449.602f, 237.378f) lineTo(452.554f, 237.378f) arcTo(0.488f, 0.488f, 0.0f, false, true, 453.042f, 237.866f) lineTo(453.042f, 239.247f) arcTo(0.488f, 0.488f, 0.0f, false, true, 452.554f, 239.735f) lineTo(449.602f, 239.735f) arcTo(0.488f, 0.488f, 0.0f, false, true, 449.114f, 239.247f) lineTo(449.114f, 237.866f) arcTo(0.488f, 0.488f, 0.0f, false, true, 449.602f, 237.378f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(457.459f, 237.378f) lineTo(460.411f, 237.378f) arcTo(0.488f, 0.488f, 0.0f, false, true, 460.899f, 237.866f) lineTo(460.899f, 239.247f) arcTo(0.488f, 0.488f, 0.0f, false, true, 460.411f, 239.735f) lineTo(457.459f, 239.735f) arcTo(0.488f, 0.488f, 0.0f, false, true, 456.97f, 239.247f) lineTo(456.97f, 237.866f) arcTo(0.488f, 0.488f, 0.0f, false, true, 457.459f, 237.378f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(465.315f, 237.378f) lineTo(468.267f, 237.378f) arcTo(0.488f, 0.488f, 0.0f, false, true, 468.756f, 237.866f) lineTo(468.756f, 239.247f) arcTo(0.488f, 0.488f, 0.0f, false, true, 468.267f, 239.735f) lineTo(465.315f, 239.735f) arcTo(0.488f, 0.488f, 0.0f, false, true, 464.827f, 239.247f) lineTo(464.827f, 237.866f) arcTo(0.488f, 0.488f, 0.0f, false, true, 465.315f, 237.378f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(473.172f, 237.378f) lineTo(476.124f, 237.378f) arcTo(0.488f, 0.488f, 0.0f, false, true, 476.612f, 237.866f) lineTo(476.612f, 239.247f) arcTo(0.488f, 0.488f, 0.0f, false, true, 476.124f, 239.735f) lineTo(473.172f, 239.735f) arcTo(0.488f, 0.488f, 0.0f, false, true, 472.684f, 239.247f) lineTo(472.684f, 237.866f) arcTo(0.488f, 0.488f, 0.0f, false, true, 473.172f, 237.378f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(481.029f, 237.378f) lineTo(483.981f, 237.378f) arcTo(0.488f, 0.488f, 0.0f, false, true, 484.469f, 237.866f) lineTo(484.469f, 239.247f) arcTo(0.488f, 0.488f, 0.0f, false, true, 483.981f, 239.735f) lineTo(481.029f, 239.735f) arcTo(0.488f, 0.488f, 0.0f, false, true, 480.541f, 239.247f) lineTo(480.541f, 237.866f) arcTo(0.488f, 0.488f, 0.0f, false, true, 481.029f, 237.378f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(488.886f, 237.378f) lineTo(491.838f, 237.378f) arcTo(0.488f, 0.488f, 0.0f, false, true, 492.326f, 237.866f) lineTo(492.326f, 239.247f) arcTo(0.488f, 0.488f, 0.0f, false, true, 491.838f, 239.735f) lineTo(488.886f, 239.735f) arcTo(0.488f, 0.488f, 0.0f, false, true, 488.398f, 239.247f) lineTo(488.398f, 237.866f) arcTo(0.488f, 0.488f, 0.0f, false, true, 488.886f, 237.378f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(496.742f, 237.378f) lineTo(499.694f, 237.378f) arcTo(0.488f, 0.488f, 0.0f, false, true, 500.183f, 237.866f) lineTo(500.183f, 239.247f) arcTo(0.488f, 0.488f, 0.0f, false, true, 499.694f, 239.735f) lineTo(496.742f, 239.735f) arcTo(0.488f, 0.488f, 0.0f, false, true, 496.254f, 239.247f) lineTo(496.254f, 237.866f) arcTo(0.488f, 0.488f, 0.0f, false, true, 496.742f, 237.378f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(504.599f, 237.378f) lineTo(507.551f, 237.378f) arcTo(0.488f, 0.488f, 0.0f, false, true, 508.039f, 237.866f) lineTo(508.039f, 239.247f) arcTo(0.488f, 0.488f, 0.0f, false, true, 507.551f, 239.735f) lineTo(504.599f, 239.735f) arcTo(0.488f, 0.488f, 0.0f, false, true, 504.111f, 239.247f) lineTo(504.111f, 237.866f) arcTo(0.488f, 0.488f, 0.0f, false, true, 504.599f, 237.378f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(512.456f, 237.378f) lineTo(515.408f, 237.378f) arcTo(0.488f, 0.488f, 0.0f, false, true, 515.896f, 237.866f) lineTo(515.896f, 239.247f) arcTo(0.488f, 0.488f, 0.0f, false, true, 515.408f, 239.735f) lineTo(512.456f, 239.735f) arcTo(0.488f, 0.488f, 0.0f, false, true, 511.968f, 239.247f) lineTo(511.968f, 237.866f) arcTo(0.488f, 0.488f, 0.0f, false, true, 512.456f, 237.378f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(520.313f, 237.378f) lineTo(523.265f, 237.378f) arcTo(0.488f, 0.488f, 0.0f, false, true, 523.753f, 237.866f) lineTo(523.753f, 239.247f) arcTo(0.488f, 0.488f, 0.0f, false, true, 523.265f, 239.735f) lineTo(520.313f, 239.735f) arcTo(0.488f, 0.488f, 0.0f, false, true, 519.825f, 239.247f) lineTo(519.825f, 237.866f) arcTo(0.488f, 0.488f, 0.0f, false, true, 520.313f, 237.378f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(440.174f, 242.092f) lineTo(470.624f, 242.092f) arcTo(0.488f, 0.488f, 0.0f, false, true, 471.113f, 242.58f) lineTo(471.113f, 243.961f) arcTo(0.488f, 0.488f, 0.0f, false, true, 470.624f, 244.449f) lineTo(440.174f, 244.449f) arcTo(0.488f, 0.488f, 0.0f, false, true, 439.686f, 243.961f) lineTo(439.686f, 242.58f) arcTo(0.488f, 0.488f, 0.0f, false, true, 440.174f, 242.092f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(536.523f, 68.508f) verticalLineToRelative(75.52f) arcToRelative(19.073f, 19.073f, 0.0f, false, true, -19.07f, 19.07f) horizontalLineToRelative(-94.52f) arcToRelative(19.081f, 19.081f, 0.0f, false, true, -17.51f, -11.49f) arcToRelative(17.984f, 17.984f, 0.0f, false, true, -1.13f, -3.51f) arcToRelative(17.367f, 17.367f, 0.0f, false, false, 6.3f, 1.17f) horizontalLineToRelative(94.52f) arcToRelative(17.605f, 17.605f, 0.0f, false, false, 17.58f, -17.58f) verticalLineToRelative(-75.52f) arcToRelative(17.368f, 17.368f, 0.0f, false, false, -1.17f, -6.3f) arcToRelative(17.988f, 17.988f, 0.0f, false, true, 3.51f, 1.13f) arcTo(19.081f, 19.081f, 0.0f, false, true, 536.523f, 68.508f) close() } path( fill = SolidColor(Color(0xFFffb6b6)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(183.897f, 368.973f) lineToRelative(-9.375f, 5.805f) lineToRelative(-27.619f, -33.95f) lineToRelative(13.836f, -8.566f) lineToRelative(23.158f, 36.711f) close() } path( fill = SolidColor(Color(0xFF2f2e41)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(191.991f, 381.099f) lineToRelative(-7.541f, 3.367f) lineToRelative(-4.524f, -6.519f) lineToRelative(-0.269f, 8.659f) lineToRelative(-20.001f, 8.929f) arcToRelative(4.924f, 4.924f, 0.0f, false, true, -6.214f, -7.056f) lineToRelative(11.048f, -18.162f) lineToRelative(-3.213f, -7.198f) lineToRelative(17.248f, -6.497f) close() } path( fill = SolidColor(Color(0xFF2f2e41)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(157.635f, 362.442f) lineToRelative(15.834f, -15.344f) curveToRelative(-11.764f, -29.029f, -12.704f, -47.022f, -29.012f, -82.131f) curveToRelative(38.693f, 7.914f, 66.356f, 7.433f, 98.356f, 0.337f) curveToRelative(10.833f, -2.365f, 38.786f, -23.956f, 36.487f, -35.027f) quadToRelative(-0.161f, -0.776f, -0.381f, -1.538f) curveToRelative(-3.435f, -11.711f, -1.016f, -29.646f, -8.451f, -42.642f) curveToRelative(-13.755f, -5.186f, -34.653f, 12.326f, -51.0f, 23.0f) curveToRelative(-7.883f, 5.147f, -2.112f, 12.161f, -8.0f, 14.0f) lineToRelative(-22.569f, 7.326f) lineToRelative(-55.373f, -8.943f) arcToRelative(20.511f, 20.511f, 0.0f, false, false, -23.376f, 17.359f) quadToRelative(-0.141f, 0.902f, -0.204f, 1.813f) curveToRelative(2.548f, 14.103f, 5.204f, 27.837f, 8.063f, 40.873f) curveToRelative(0.923f, 4.208f, -0.635f, 12.275f, 2.836f, 12.395f) reflectiveCurveToRelative(2.073f, 8.464f, 3.154f, 12.543f) curveTo(130.654f, 331.579f, 134.863f, 343.733f, 157.635f, 362.442f) close() } path( fill = SolidColor(Color(0xFFffb6b6)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(127.329f, 429.745f) lineToRelative(-9.767f, -5.098f) lineToRelative(15.453f, -40.936f) lineToRelative(14.415f, 7.524f) lineToRelative(-20.101f, 38.51f) close() } path( fill = SolidColor(Color(0xFF2f2e41)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(125.701f, 399.184f) lineToRelative(15.661f, 10.783f) curveToRelative(19.195f, -27.76f, 31.658f, -64.854f, 40.939f, -106.36f) curveToRelative(36.139f, -15.765f, 67.761f, -33.801f, 89.796f, -57.993f) arcToRelative(20.915f, 20.915f, 0.0f, false, false, -1.209f, -29.095f) quadToRelative(-0.578f, -0.542f, -1.196f, -1.039f) curveToRelative(-9.545f, -7.603f, -30.743f, -3.228f, -44.292f, -9.583f) lineToRelative(-24.204f, 33.5f) lineToRelative(2.782f, 7.803f) lineToRelative(-8.42f, 4.099f) lineToRelative(-7.109f, 3.461f) lineToRelative(-8.138f, 3.962f) lineTo(153.633f, 271.708f) arcToRelative(20.484f, 20.484f, 0.0f, false, false, -9.089f, 27.629f) quadToRelative(0.404f, 0.818f, 0.878f, 1.6f) lineToRelative(-9.908f, 49.36f) reflectiveCurveToRelative(2.726f, 8.593f, -1.563f, 7.789f) reflectiveCurveToRelative(-1.952f, 9.725f, -1.952f, 9.725f) close() } path( fill = SolidColor(Color(0xFF2f2e41)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(121.414f, 452.305f) lineToRelative(-7.321f, -3.821f) lineToRelative(2.301f, -7.594f) lineToRelative(-6.955f, 5.165f) lineToRelative(-19.418f, -10.135f) arcToRelative(4.924f, 4.924f, 0.0f, false, true, 1.673f, -9.252f) lineToRelative(21.096f, -2.616f) lineToRelative(3.647f, -6.988f) lineToRelative(15.802f, 9.486f) close() } path( fill = SolidColor(Color(0xFFffb6b6)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(334.892f, 237.703f) lineToRelative(-11.423f, -27.606f) lineToRelative(-9.0f, 5.0f) lineToRelative(5.435f, 28.082f) arcToRelative(10.001f, 10.001f, 0.0f, true, false, 14.988f, -5.476f) close() } path( fill = SolidColor(Color(0xFFffb6b6)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(143.208f, 19.407f) lineToRelative(17.381f, 24.3f) lineToRelative(7.633f, -6.909f) lineTo(156.565f, 10.677f) arcToRelative(10.001f, 10.001f, 0.0f, true, false, -13.357f, 8.73f) close() } path( fill = SolidColor(FixedAccentColors.tertiaryFixedDim), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(221.697f, 173.421f) curveToRelative(-11.274f, 17.486f, 30.028f, 55.586f, 51.772f, 46.677f) curveToRelative(3.807f, -1.56f, 16.904f, -6.713f, 7.952f, -13.856f) reflectiveCurveToRelative(1.038f, 0.851f, 1.274f, -3.799f) curveToRelative(0.158f, -3.109f, -2.249f, -9.929f, 0.262f, -9.637f) reflectiveCurveToRelative(3.953f, -6.971f, -1.517f, -8.822f) arcToRelative(10.618f, 10.618f, 0.0f, false, true, -7.0f, -9.0f) curveToRelative(-0.523f, -19.167f, 5.968f, -39.177f, 5.968f, -39.177f) lineToRelative(26.498f, 67.735f) reflectiveCurveToRelative(-0.196f, 11.719f, 3.183f, 8.137f) reflectiveCurveToRelative(3.133f, 8.009f, 3.133f, 8.009f) reflectiveCurveToRelative(5.025f, 3.507f, 2.135f, 5.458f) reflectiveCurveToRelative(3.111f, 7.952f, 3.111f, 7.952f) lineToRelative(14.0f, -1.0f) reflectiveCurveToRelative(-0.219f, -10.4f, -2.61f, -12.7f) reflectiveCurveToRelative(-1.216f, -5.919f, -1.216f, -5.919f) reflectiveCurveToRelative(-4.928f, -4.584f, -2.051f, -9.983f) reflectiveCurveToRelative(-20.951f, -101.961f, -20.951f, -101.961f) arcToRelative(25.72f, 25.72f, 0.0f, false, false, -18.366f, -19.62f) lineToRelative(-15.121f, -4.163f) lineToRelative(-2.685f, -10.653f) lineTo(248.177f, 67.098f) lineToRelative(-3.709f, 7.0f) lineToRelative(-34.0f, -3.0f) reflectiveCurveToRelative(-33.643f, -30.61f, -32.0f, -33.0f) reflectiveCurveToRelative(-3.573f, -7.986f, -3.573f, -7.986f) reflectiveCurveToRelative(-4.454f, -1.458f, -1.941f, -2.236f) reflectiveCurveToRelative(-2.237f, -2.578f, -2.237f, -2.578f) reflectiveCurveToRelative(-4.4f, -1.156f, -2.325f, -2.678f) reflectiveCurveToRelative(-6.925f, -2.522f, -6.925f, -2.522f) lineToRelative(-11.677f, 9.528f) lineToRelative(2.36f, 4.002f) reflectiveCurveToRelative(-1.439f, 5.449f, 0.439f, 3.46f) reflectiveCurveToRelative(2.232f, 3.784f, 2.232f, 3.784f) reflectiveCurveToRelative(0.959f, 6.322f, 6.646f, 8.227f) reflectiveCurveToRelative(10.0f, 20.0f, 10.0f, 20.0f) lineToRelative(48.0f, 40.0f) curveToRelative(-7.311f, 8.391f, -5.57f, 17.166f, 0.906f, 26.142f) close() } path( fill = SolidColor(Color(0xFFffb6b6)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(258.343f, 36.964f) moveToRelative(-23.056f, 0.0f) arcToRelative(23.056f, 23.056f, 0.0f, true, true, 46.111f, 0.0f) arcToRelative(23.056f, 23.056f, 0.0f, true, true, -46.111f, 0.0f) } path( fill = SolidColor(Color(0xFF2f2e41)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(278.469f, 30.098f) curveToRelative(-0.59f, 0.17f, -9.25f, 1.3f, -13.0f, 2.0f) curveToRelative(-3.68f, 0.69f, -4.32f, -4.0f, -7.33f, -3.12f) arcToRelative(0.127f, 0.127f, 0.0f, false, false, -0.01f, -0.06f) arcToRelative(28.344f, 28.344f, 0.0f, false, false, -2.37f, -7.17f) arcToRelative(21.178f, 21.178f, 0.0f, false, true, 0.17f, 6.6f) arcToRelative(0.984f, 0.984f, 0.0f, false, true, -0.02f, 0.17f) curveToRelative(-0.01f, 0.1f, -0.03f, 0.19f, -0.04f, 0.29f) curveToRelative(-3.18f, -1.4f, -5.2f, -0.15f, -7.4f, 0.29f) curveToRelative(-2.46f, 0.49f, -4.38f, 1.57f, -3.0f, 10.0f) curveToRelative(2.83f, 17.34f, 17.55f, 23.53f, 15.0f, 35.0f) curveToRelative(-0.5f, 2.27f, -1.76f, 5.11f, -3.34f, 5.82f) arcToRelative(28.344f, 28.344f, 0.0f, false, false, -2.37f, -7.17f) arcToRelative(21.178f, 21.178f, 0.0f, false, true, 0.17f, 6.6f) curveToRelative(-2.45f, -2.65f, -4.22f, -11.91f, -12.46f, -19.25f) curveToRelative(-2.03f, -1.8f, -12.2f, -12.48f, -13.42f, -15.0f) arcToRelative(32.136f, 32.136f, 0.0f, false, true, 0.54f, 6.11f) arcToRelative(29.416f, 29.416f, 0.0f, false, true, -0.28f, 4.1f) arcToRelative(1.151f, 1.151f, 0.0f, false, true, -0.03f, 0.26f) curveToRelative(-0.01f, 0.16f, -0.04f, 0.3f, -0.06f, 0.45f) curveToRelative(-0.02f, 0.17f, -0.05f, 0.33f, -0.08f, 0.5f) curveToRelative(-3.33f, -0.48f, -6.28f, -1.06f, -7.67f, -2.42f) curveToRelative(-6.76f, -6.58f, 1.49f, -19.4f, 6.0f, -31.0f) curveToRelative(6.45f, -16.61f, 25.18f, -18.82f, 27.0f, -19.0f) curveToRelative(8.02f, -0.81f, 21.57f, 0.91f, 26.0f, 10.0f) curveTo(283.569f, 20.468f, 281.589f, 29.198f, 278.469f, 30.098f) close() } path( fill = SolidColor(Color(0xFF2f2e41)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(229.854f, 33.847f) lineToRelative(16.701f, -25.98f) arcToRelative(25.612f, 25.612f, 0.0f, false, false, -13.188f, -3.46f) arcToRelative(16.419f, 16.419f, 0.0f, false, false, -12.109f, 5.711f) curveToRelative(-3.411f, 4.239f, -3.996f, 10.232f, -2.718f, 15.521f) curveTo(218.981f, 27.466f, 227.385f, 21.039f, 225.385f, 24.039f) curveToRelative(-1.642f, 2.349f, -4.327f, 12.222f, -5.0f, 15.0f) curveToRelative(-1.084f, 4.472f, -4.063f, 9.062f, -1.409f, 12.821f) curveToRelative(3.139f, 4.444f, 8.74f, 6.654f, 14.177f, 6.878f) reflectiveCurveToRelative(10.788f, -1.282f, 15.954f, -2.989f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.inversePrimary), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(236.271f, 32.822f) arcToRelative(1.0f, 1.0f, 0.0f, false, true, -0.273f, -1.962f) curveToRelative(0.262f, -0.433f, 0.155f, -2.253f, 0.068f, -3.725f) curveToRelative(-0.325f, -5.524f, -0.87f, -14.769f, 8.106f, -21.01f) arcToRelative(1.0f, 1.0f, 0.0f, false, true, 1.142f, 1.642f) curveToRelative(-8.055f, 5.601f, -7.571f, 13.815f, -7.251f, 19.25f) curveToRelative(0.183f, 3.107f, 0.315f, 5.352f, -1.57f, 5.779f) arcTo(0.994f, 0.994f, 0.0f, false, true, 236.271f, 32.822f) close() } path( fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(0.0f, 451.951f) arcToRelative(1.186f, 1.186f, 0.0f, false, false, 1.183f, 1.19f) lineTo(716.48f, 453.141f) arcToRelative(1.19f, 1.19f, 0.0f, false, false, 0.0f, -2.38f) horizontalLineToRelative(-715.29f) arcToRelative(1.186f, 1.186f, 0.0f, false, false, -1.19f, 1.183f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.inversePrimary), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(442.535f, 94.849f) lineTo(427.403f, 94.849f) arcToRelative(1.513f, 1.513f, 0.0f, false, true, 0.0f, -3.026f) horizontalLineToRelative(15.132f) arcToRelative(1.513f, 1.513f, 0.0f, false, true, 0.0f, 3.026f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.inversePrimary), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(469.772f, 94.849f) lineTo(454.64f, 94.849f) arcToRelative(1.513f, 1.513f, 0.0f, true, true, 0.0f, -3.026f) horizontalLineToRelative(15.132f) arcToRelative(1.513f, 1.513f, 0.0f, true, true, 0.0f, 3.026f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.inversePrimary), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(483.391f, 106.955f) lineTo(468.259f, 106.955f) arcToRelative(1.513f, 1.513f, 0.0f, true, true, 0.0f, -3.026f) horizontalLineToRelative(15.132f) arcToRelative(1.513f, 1.513f, 0.0f, true, true, 0.0f, 3.026f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.inversePrimary), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(456.237f, 106.955f) lineTo(427.403f, 106.955f) arcToRelative(1.513f, 1.513f, 0.0f, false, true, 0.0f, -3.026f) lineTo(456.237f, 103.929f) arcToRelative(1.513f, 1.513f, 0.0f, false, true, 0.0f, 3.026f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.inversePrimary), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(510.713f, 94.849f) lineTo(481.878f, 94.849f) arcToRelative(1.513f, 1.513f, 0.0f, false, true, 0.0f, -3.026f) horizontalLineToRelative(28.834f) arcToRelative(1.513f, 1.513f, 0.0f, true, true, 0.0f, 3.026f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.inversePrimary), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(452.895f, 71.925f) lineTo(424.06f, 71.925f) arcToRelative(1.513f, 1.513f, 0.0f, true, true, 0.0f, -3.026f) horizontalLineToRelative(28.834f) arcToRelative(1.513f, 1.513f, 0.0f, true, true, 0.0f, 3.026f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.inversePrimary), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(455.265f, 80.766f) arcToRelative(1.355f, 1.355f, 0.0f, false, true, -0.832f, -2.425f) lineToRelative(7.383f, -5.739f) arcToRelative(2.714f, 2.714f, 0.0f, false, false, 0.328f, -3.984f) lineToRelative(-6.199f, -6.711f) arcToRelative(1.355f, 1.355f, 0.0f, true, true, 1.99f, -1.839f) lineToRelative(9.205f, 9.965f) arcToRelative(1.355f, 1.355f, 0.0f, false, true, -0.163f, 1.99f) lineToRelative(-10.881f, 8.458f) arcTo(1.351f, 1.351f, 0.0f, false, true, 455.265f, 80.766f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.inversePrimary), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(422.601f, 80.766f) arcToRelative(1.355f, 1.355f, 0.0f, false, false, 0.832f, -2.425f) lineToRelative(-7.383f, -5.739f) arcToRelative(2.714f, 2.714f, 0.0f, false, true, -0.328f, -3.984f) lineToRelative(6.199f, -6.711f) arcToRelative(1.355f, 1.355f, 0.0f, false, false, -1.99f, -1.839f) lineToRelative(-9.205f, 9.965f) arcToRelative(1.355f, 1.355f, 0.0f, false, false, 0.163f, 1.99f) lineToRelative(10.881f, 8.458f) arcTo(1.351f, 1.351f, 0.0f, false, false, 422.601f, 80.766f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.inversePrimary), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(467.801f, 132.906f) lineTo(438.966f, 132.906f) arcToRelative(1.513f, 1.513f, 0.0f, false, true, 0.0f, -3.026f) horizontalLineToRelative(28.834f) arcToRelative(1.513f, 1.513f, 0.0f, false, true, 0.0f, 3.026f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.inversePrimary), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(472.882f, 140.391f) arcToRelative(1.355f, 1.355f, 0.0f, false, true, -0.832f, -2.425f) lineToRelative(7.383f, -5.739f) arcToRelative(2.714f, 2.714f, 0.0f, false, false, 0.328f, -3.984f) lineToRelative(-6.199f, -6.711f) arcToRelative(1.355f, 1.355f, 0.0f, false, true, 1.99f, -1.839f) lineToRelative(9.205f, 9.965f) arcToRelative(1.355f, 1.355f, 0.0f, false, true, -0.163f, 1.99f) lineToRelative(-10.881f, 8.458f) arcTo(1.351f, 1.351f, 0.0f, false, true, 472.882f, 140.391f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.inversePrimary), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(422.601f, 140.391f) arcToRelative(1.355f, 1.355f, 0.0f, false, false, 0.832f, -2.425f) lineToRelative(-7.383f, -5.739f) arcToRelative(2.714f, 2.714f, 0.0f, false, true, -0.328f, -3.984f) lineToRelative(6.199f, -6.711f) arcToRelative(1.355f, 1.355f, 0.0f, true, false, -1.99f, -1.839f) lineToRelative(-9.205f, 9.965f) arcToRelative(1.355f, 1.355f, 0.0f, false, false, 0.163f, 1.99f) lineToRelative(10.881f, 8.458f) arcTo(1.351f, 1.351f, 0.0f, false, false, 422.601f, 140.391f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.inversePrimary), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(425.174f, 136.151f) lineToRelative(6.061f, -13.865f) arcToRelative(1.513f, 1.513f, 0.0f, false, true, 2.773f, 1.212f) lineToRelative(-6.061f, 13.865f) arcToRelative(1.513f, 1.513f, 0.0f, true, true, -2.773f, -1.212f) close() } } .build() } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/svg/drawablevectors/Download.kt ================================================ package com.junkfood.seal.ui.svg.drawablevectors import androidx.compose.foundation.Image import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType.Companion.NonZero import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap.Companion.Butt import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.junkfood.seal.ui.common.LocalFixedColorRoles import com.junkfood.seal.ui.svg.DynamicColorImageVectors @Composable fun DynamicColorImageVectors.download(): ImageVector { return Builder( name = "Download", defaultWidth = 765.59973.dp, defaultHeight = 667.7441.dp, viewportWidth = 765.59973f, viewportHeight = 667.7441f, ) .apply { path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainerHigh), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(713.462f, 666.985f) verticalLineToRelative(-72.34f) reflectiveCurveTo(741.654f, 645.931f, 713.462f, 666.985f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainerHigh), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(715.203f, 666.972f) lineToRelative(-53.29f, -48.921f) reflectiveCurveTo(718.759f, 631.966f, 715.203f, 666.972f) close() } path( fill = SolidColor(Color(0xFF3f3d56)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(270.217f, 128.781f) horizontalLineToRelative(-2.978f) lineTo(267.239f, 47.211f) arcTo(47.211f, 47.211f, 0.0f, false, false, 220.029f, 0.0f) lineTo(47.211f, 0.0f) arcToRelative(47.211f, 47.211f, 0.0f, false, false, -47.211f, 47.211f) lineTo(0.0f, 494.712f) arcToRelative(47.211f, 47.211f, 0.0f, false, false, 47.211f, 47.211f) lineTo(220.028f, 541.923f) arcToRelative(47.211f, 47.211f, 0.0f, false, false, 47.211f, -47.211f) verticalLineToRelative(-307.868f) horizontalLineToRelative(2.978f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surface), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(221.934f, 12.283f) lineTo(63.375f, 12.283f) arcToRelative(16.75f, 16.75f, 0.0f, false, true, -15.508f, 23.076f) lineTo(84.861f, 35.359f) arcToRelative(16.75f, 16.75f, 0.0f, false, true, -15.508f, -23.076f) lineTo(48.283f, 12.283f) arcTo(35.256f, 35.256f, 0.0f, false, false, 13.027f, 47.539f) lineTo(13.027f, 494.384f) arcToRelative(35.256f, 35.256f, 0.0f, false, false, 35.256f, 35.256f) lineTo(221.934f, 529.64f) arcToRelative(35.256f, 35.256f, 0.0f, false, false, 35.256f, -35.256f) horizontalLineToRelative(0.0f) lineTo(257.19f, 47.539f) arcTo(35.256f, 35.256f, 0.0f, false, false, 221.934f, 12.283f) close() } path( fill = SolidColor(LocalFixedColorRoles.current.primaryFixedDim), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(135.108f, 261.234f) moveToRelative(-84.446f, 0.0f) arcToRelative(84.446f, 84.446f, 0.0f, true, true, 168.892f, 0.0f) arcToRelative(84.446f, 84.446f, 0.0f, true, true, -168.892f, 0.0f) } path( fill = SolidColor(LocalFixedColorRoles.current.onPrimaryFixedVariant), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(160.132f, 267.004f) lineToRelative(-21.0f, 21.63f) arcToRelative(4.774f, 4.774f, 0.0f, false, true, -3.5f, 1.41f) horizontalLineToRelative(-0.07f) arcToRelative(4.814f, 4.814f, 0.0f, false, true, -3.51f, -1.41f) lineToRelative(-20.99f, -21.63f) curveToRelative(-0.07f, -0.08f, -0.15f, -0.15f, -0.22f, -0.22f) arcToRelative(4.641f, 4.641f, 0.0f, false, true, 0.22f, -6.55f) arcToRelative(5.169f, 5.169f, 0.0f, false, true, 7.08f, 0.0f) lineToRelative(12.44f, 13.0f) verticalLineToRelative(-33.52f) arcToRelative(5.02f, 5.02f, 0.0f, false, true, 10.03f, 0.0f) verticalLineToRelative(33.52f) lineToRelative(12.43f, -13.0f) arcToRelative(5.181f, 5.181f, 0.0f, false, true, 7.09f, 0.0f) curveToRelative(0.07f, 0.07f, 0.14f, 0.14f, 0.22f, 0.22f) arcTo(4.65f, 4.65f, 0.0f, false, true, 160.132f, 267.004f) close() } path( fill = SolidColor(Color(0xFF9f616a)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(590.258f, 653.281f) lineToRelative(-12.26f, -0.001f) lineToRelative(-5.832f, -47.288f) lineToRelative(18.094f, 0.001f) lineToRelative(-0.002f, 47.288f) close() } path( fill = SolidColor(Color(0xFF2f2e41)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(593.384f, 665.165f) lineToRelative(-39.531f, -0.001f) verticalLineToRelative(-0.5f) arcToRelative(15.387f, 15.387f, 0.0f, false, true, 15.386f, -15.386f) horizontalLineToRelative(0.001f) lineToRelative(24.144f, 0.001f) close() } path( fill = SolidColor(Color(0xFF9f616a)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(497.121f, 652.877f) lineToRelative(-11.844f, -3.167f) lineToRelative(6.58f, -47.19f) lineToRelative(17.48f, 4.674f) lineToRelative(-12.216f, 45.683f) close() } path( fill = SolidColor(Color(0xFF2f2e41)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(497.072f, 665.165f) lineToRelative(-38.189f, -10.212f) lineToRelative(0.129f, -0.483f) arcTo(15.387f, 15.387f, 0.0f, false, true, 477.851f, 643.58f) lineToRelative(0.001f, 0.0f) lineToRelative(23.324f, 6.237f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.onBackground), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(597.311f, 388.269f) lineToRelative(10.269f, 11.93f) lineToRelative(-13.281f, 242.443f) lineToRelative(-28.368f, 0.0f) lineToRelative(-14.303f, -186.551f) lineToRelative(-44.346f, 191.554f) lineToRelative(-29.492f, -7.233f) lineToRelative(26.816f, -243.299f) lineToRelative(92.705f, -8.844f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceVariant), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(511.462f, 231.744f) lineToRelative(28.246f, -14.167f) lineToRelative(43.437f, 0.764f) lineToRelative(37.38f, 19.127f) lineTo(599.257f, 343.681f) lineToRelative(9.187f, 55.38f) lineToRelative(-0.0f, 0.0f) arcToRelative(226.532f, 226.532f, 0.0f, false, true, -108.335f, 0.892f) lineToRelative(-0.284f, -0.068f) reflectiveCurveToRelative(21.114f, -74.916f, 12.126f, -97.779f) close() } path( fill = SolidColor(Color(0xFF9f616a)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(593.177f, 175.136f) arcToRelative(32.002f, 32.002f, 0.0f, true, false, 0.0f, 0.237f) quadTo(593.178f, 175.254f, 593.177f, 175.136f) close() } path( fill = SolidColor(Color(0xFF2f2e41)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(546.845f, 156.85f) arcToRelative(9.079f, 9.079f, 0.0f, false, true, 6.117f, -2.468f) curveToRelative(3.419f, -0.111f, 8.542f, 1.103f, 10.722f, 3.883f) curveToRelative(1.752f, 2.234f, 1.836f, 5.307f, 1.843f, 8.147f) lineToRelative(0.019f, 7.836f) curveToRelative(0.005f, 2.318f, 0.034f, 4.735f, 1.087f, 6.801f) reflectiveCurveToRelative(3.471f, 3.634f, 5.68f, 2.93f) curveToRelative(2.62f, -0.835f, 3.772f, -4.318f, 6.45f, -4.944f) curveToRelative(2.011f, -0.47f, 4.077f, 0.999f, 4.956f, 2.868f) arcToRelative(12.439f, 12.439f, 0.0f, false, true, 0.716f, 6.086f) curveToRelative(-0.253f, 4.103f, -6.138f, 5.731f, -6.971f, 9.756f) curveToRelative(-0.482f, 2.329f, -2.169f, 6.973f, 0.0f, 6.0f) curveToRelative(10.0f, -1.0f, 14.389f, -6.841f, 18.966f, -12.339f) lineToRelative(10.323f, -12.401f) arcToRelative(12.121f, 12.121f, 0.0f, false, false, 2.659f, -4.302f) arcToRelative(11.951f, 11.951f, 0.0f, false, false, 0.273f, -3.431f) quadToRelative(-0.084f, -4.623f, -0.251f, -9.244f) curveToRelative(-0.097f, -2.692f, -0.774f, -6.088f, -3.41f, -6.641f) curveToRelative(-1.371f, -0.288f, -3.185f, 0.214f, -3.91f, -0.985f) arcToRelative(2.721f, 2.721f, 0.0f, false, true, -0.124f, -1.911f) curveToRelative(0.582f, -3.076f, 1.564f, -6.294f, 0.551f, -9.257f) curveToRelative(-1.527f, -4.468f, -6.785f, -6.302f, -11.431f, -7.145f) reflectiveCurveToRelative(-9.862f, -1.568f, -12.778f, -5.282f) arcToRelative(40.501f, 40.501f, 0.0f, false, false, -2.536f, -3.565f) arcToRelative(9.956f, 9.956f, 0.0f, false, false, -4.755f, -2.398f) arcToRelative(26.279f, 26.279f, 0.0f, false, false, -17.28f, 1.534f) curveToRelative(-2.247f, 1.026f, -4.505f, 2.407f, -6.97f, 2.25f) curveToRelative(-2.561f, -0.162f, -4.748f, -1.965f, -7.276f, -2.403f) curveToRelative(-4.084f, -0.708f, -7.988f, 2.351f, -10.0f, 5.975f) curveToRelative(-2.494f, 4.49f, -2.722f, 10.638f, 0.8f, 14.375f) curveToRelative(1.757f, 1.864f, 4.38f, 3.145f, 5.109f, 5.601f) curveToRelative(0.297f, 1.0f, 0.228f, 2.076f, 0.491f, 3.086f) arcToRelative(5.57f, 5.57f, 0.0f, false, false, 4.489f, 3.884f) curveTo(542.898f, 159.551f, 545.01f, 158.407f, 546.845f, 156.85f) close() } path( fill = SolidColor(Color(0xFF9f616a)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(507.563f, 309.185f) arcToRelative(11.462f, 11.462f, 0.0f, false, false, 16.65f, 5.627f) lineToRelative(57.353f, 30.318f) lineToRelative(1.857f, -13.971f) lineTo(527.695f, 298.296f) arcToRelative(11.524f, 11.524f, 0.0f, false, false, -20.131f, 10.889f) close() } path( fill = SolidColor(Color(0xFF9f616a)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(583.992f, 311.6f) arcToRelative(11.462f, 11.462f, 0.0f, false, true, -17.478f, 1.848f) lineTo(503.917f, 330.483f) lineToRelative(0.545f, -17.738f) lineToRelative(62.269f, -16.174f) arcToRelative(11.524f, 11.524f, 0.0f, false, true, 17.261f, 15.03f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceVariant), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(608.319f, 238.232f) lineToRelative(12.205f, -0.765f) reflectiveCurveToRelative(14.29f, 18.855f, 6.364f, 39.316f) curveToRelative(0.0f, 0.0f, 1.373f, 73.499f, -30.276f, 70.48f) reflectiveCurveToRelative(-41.65f, -3.019f, -41.65f, -3.019f) lineToRelative(9.5f, -26.5f) lineToRelative(21.253f, -6.562f) reflectiveCurveToRelative(-6.55f, -28.894f, 5.849f, -40.916f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceVariant), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(520.187f, 237.587f) lineToRelative(-1.725f, -8.843f) reflectiveCurveToRelative(-25.44f, -0.598f, -30.47f, 37.951f) curveToRelative(0.0f, 0.0f, -22.877f, 57.692f, -0.454f, 65.121f) reflectiveCurveToRelative(47.089f, 0.0f, 47.089f, 0.0f) lineToRelative(-1.858f, -25.442f) lineToRelative(-24.673f, -5.035f) reflectiveCurveToRelative(12.745f, -16.489f, 5.805f, -30.792f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceVariant), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(764.6f, 667.744f) horizontalLineToRelative(-381.0f) arcToRelative(1.0f, 1.0f, 0.0f, false, true, 0.0f, -2.0f) horizontalLineToRelative(381.0f) arcToRelative(1.0f, 1.0f, 0.0f, false, true, 0.0f, 2.0f) close() } } .build() } @Preview @Composable private fun Preview() { val painter = rememberVectorPainter(DynamicColorImageVectors.download()) Image(painter, null) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/svg/drawablevectors/VideoFiles.kt ================================================ package com.junkfood.seal.ui.svg.drawablevectors import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType.Companion.NonZero import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap.Companion.Butt import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp import com.junkfood.seal.ui.svg.DynamicColorImageVectors @Composable fun DynamicColorImageVectors.videoFiles(): ImageVector { return Builder( name = "VideoFiles", defaultWidth = 1008.9205.dp, defaultHeight = 607.45.dp, viewportWidth = 1008.9205f, viewportHeight = 607.45f, ) .apply { path( fill = SolidColor(MaterialTheme.colorScheme.surfaceVariant), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(1008.92f, 474.03f) arcToRelative(15.34f, 15.34f, 0.0f, false, true, -15.26f, 15.42f) lineTo(322.26f, 489.45f) arcToRelative(15.34f, 15.34f, 0.0f, false, true, -15.26f, -15.42f) lineTo(307.0f, 15.42f) arcToRelative(15.34f, 15.34f, 0.0f, false, true, 15.26f, -15.42f) lineTo(993.66f, 0.0f) arcToRelative(15.34f, 15.34f, 0.0f, false, true, 15.26f, 15.42f) verticalLineToRelative(0.0f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(1001.0f, 466.52f) arcToRelative(14.91f, 14.91f, 0.0f, false, true, -14.91f, 14.91f) lineTo(330.29f, 481.43f) arcToRelative(14.91f, 14.91f, 0.0f, false, true, -14.91f, -14.91f) lineTo(315.38f, 23.14f) arcToRelative(14.91f, 14.91f, 0.0f, false, true, 14.91f, -14.91f) horizontalLineToRelative(655.83f) arcToRelative(14.91f, 14.91f, 0.0f, false, true, 14.88f, 14.91f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceVariant), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(350.43f, 27.48f) horizontalLineToRelative(616.22f) arcToRelative(8.85f, 8.85f, 0.0f, false, true, 8.85f, 8.85f) verticalLineTo(451.61f) arcToRelative(8.85f, 8.85f, 0.0f, false, true, -8.85f, 8.85f) horizontalLineToRelative(-616.22f) arcToRelative(8.85f, 8.85f, 0.0f, false, true, -8.85f, -8.85f) verticalLineTo(36.33f) arcToRelative(8.85f, 8.85f, 0.0f, false, true, 8.85f, -8.85f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(358.23f, 37.44f) horizontalLineToRelative(599.95f) arcToRelative(8.85f, 8.85f, 0.0f, false, true, 8.85f, 8.85f) verticalLineTo(439.41f) arcToRelative(8.85f, 8.85f, 0.0f, false, true, -8.85f, 8.85f) horizontalLineToRelative(-599.95f) arcToRelative(8.85f, 8.85f, 0.0f, false, true, -8.85f, -8.85f) verticalLineTo(46.29f) arcTo(8.85f, 8.85f, 0.0f, false, true, 358.23f, 37.44f) close() } path( fill = SolidColor(Color(0xFF3f3d56)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(781.84f, 310.53f) arcToRelative(16.87f, 16.87f, 0.0f, false, true, -6.85f, -1.46f) arcToRelative(8.31f, 8.31f, 0.0f, false, true, -1.43f, -0.81f) lineToRelative(-43.6f, -30.69f) horizontalLineToRelative(0.0f) arcToRelative(16.87f, 16.87f, 0.0f, false, true, -7.16f, -13.79f) lineTo(722.79f, 221.92f) arcToRelative(16.87f, 16.87f, 0.0f, false, true, 7.16f, -13.79f) lineToRelative(43.6f, -30.69f) arcToRelative(8.31f, 8.31f, 0.0f, false, true, 1.43f, -0.81f) arcTo(16.87f, 16.87f, 0.0f, false, true, 798.71f, 192.04f) lineTo(798.71f, 293.66f) arcToRelative(16.87f, 16.87f, 0.0f, false, true, -16.87f, 16.87f) close() } path( fill = SolidColor(Color(0xFF3f3d56)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(684.26f, 318.76f) lineTo(562.65f, 318.76f) curveToRelative(-24.81f, -0.02f, -44.92f, -16.06f, -44.94f, -35.85f) lineTo(517.71f, 202.79f) curveToRelative(0.03f, -19.79f, 20.13f, -35.83f, 44.94f, -35.85f) lineTo(684.57f, 166.94f) curveToRelative(24.63f, 0.02f, 44.6f, 15.95f, 44.62f, 35.59f) verticalLineToRelative(80.38f) curveTo(729.17f, 302.7f, 709.07f, 318.74f, 684.26f, 318.76f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceVariant), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(900.88f, 386.81f) moveToRelative(-36.17f, 0.0f) arcToRelative(36.17f, 36.17f, 0.0f, true, true, 72.33f, 0.0f) arcToRelative(36.17f, 36.17f, 0.0f, true, true, -72.33f, 0.0f) } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(919.66f, 386.17f) lineToRelative(-28.83f, -16.64f) arcToRelative(0.75f, 0.75f, 0.0f, false, false, -1.12f, 0.65f) verticalLineToRelative(33.29f) arcToRelative(0.75f, 0.75f, 0.0f, false, false, 1.12f, 0.65f) lineToRelative(28.83f, -16.64f) arcToRelative(0.75f, 0.75f, 0.0f, false, false, 0.0f, -1.3f) lineToRelative(-28.83f, -16.64f) arcToRelative(0.75f, 0.75f, 0.0f, false, false, -1.12f, 0.65f) verticalLineToRelative(33.29f) arcToRelative(0.75f, 0.75f, 0.0f, false, false, 1.12f, 0.65f) lineToRelative(28.83f, -16.64f) arcTo(0.75f, 0.75f, 0.0f, false, false, 919.66f, 386.17f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceVariant), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(853.92f, 529.03f) arcToRelative(15.34f, 15.34f, 0.0f, false, true, -15.26f, 15.42f) lineTo(167.26f, 544.45f) arcToRelative(15.34f, 15.34f, 0.0f, false, true, -15.26f, -15.42f) lineTo(152.0f, 70.42f) arcToRelative(15.34f, 15.34f, 0.0f, false, true, 15.26f, -15.42f) lineTo(838.66f, 55.0f) arcToRelative(15.34f, 15.34f, 0.0f, false, true, 15.26f, 15.42f) verticalLineToRelative(0.0f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(846.0f, 521.52f) arcToRelative(14.91f, 14.91f, 0.0f, false, true, -14.91f, 14.91f) lineTo(175.29f, 536.43f) arcToRelative(14.91f, 14.91f, 0.0f, false, true, -14.91f, -14.91f) lineTo(160.38f, 78.14f) arcToRelative(14.91f, 14.91f, 0.0f, false, true, 14.91f, -14.91f) lineTo(831.12f, 63.23f) arcToRelative(14.91f, 14.91f, 0.0f, false, true, 14.88f, 14.91f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceVariant), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(195.43f, 82.48f) horizontalLineToRelative(616.22f) arcToRelative(8.85f, 8.85f, 0.0f, false, true, 8.85f, 8.85f) verticalLineTo(506.61f) arcToRelative(8.85f, 8.85f, 0.0f, false, true, -8.85f, 8.85f) horizontalLineToRelative(-616.22f) arcToRelative(8.85f, 8.85f, 0.0f, false, true, -8.85f, -8.85f) verticalLineTo(91.33f) arcToRelative(8.85f, 8.85f, 0.0f, false, true, 8.85f, -8.85f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(203.23f, 92.44f) horizontalLineToRelative(599.95f) arcToRelative(8.85f, 8.85f, 0.0f, false, true, 8.85f, 8.85f) verticalLineTo(494.41f) arcToRelative(8.85f, 8.85f, 0.0f, false, true, -8.85f, 8.85f) horizontalLineToRelative(-599.95f) arcToRelative(8.85f, 8.85f, 0.0f, false, true, -8.85f, -8.85f) verticalLineTo(101.29f) arcTo(8.85f, 8.85f, 0.0f, false, true, 203.23f, 92.44f) close() } path( fill = SolidColor(Color(0xFF3f3d56)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(626.84f, 365.53f) arcToRelative(16.87f, 16.87f, 0.0f, false, true, -6.85f, -1.46f) arcToRelative(8.31f, 8.31f, 0.0f, false, true, -1.43f, -0.81f) lineToRelative(-43.6f, -30.69f) horizontalLineToRelative(0.0f) arcToRelative(16.87f, 16.87f, 0.0f, false, true, -7.16f, -13.79f) lineTo(567.79f, 276.92f) arcToRelative(16.87f, 16.87f, 0.0f, false, true, 7.16f, -13.79f) lineToRelative(43.6f, -30.69f) arcToRelative(8.31f, 8.31f, 0.0f, false, true, 1.43f, -0.81f) arcTo(16.87f, 16.87f, 0.0f, false, true, 643.71f, 247.04f) lineTo(643.71f, 348.66f) arcToRelative(16.87f, 16.87f, 0.0f, false, true, -16.87f, 16.87f) close() } path( fill = SolidColor(Color(0xFF3f3d56)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(529.26f, 373.76f) lineTo(407.65f, 373.76f) curveToRelative(-24.81f, -0.02f, -44.92f, -16.06f, -44.94f, -35.85f) lineTo(362.71f, 257.79f) curveToRelative(0.03f, -19.79f, 20.13f, -35.83f, 44.94f, -35.85f) lineTo(529.57f, 221.94f) curveToRelative(24.63f, 0.02f, 44.6f, 15.95f, 44.62f, 35.59f) verticalLineToRelative(80.38f) curveTo(574.17f, 357.7f, 554.07f, 373.74f, 529.26f, 373.76f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceVariant), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(745.88f, 441.81f) moveToRelative(-36.17f, 0.0f) arcToRelative(36.17f, 36.17f, 0.0f, true, true, 72.33f, 0.0f) arcToRelative(36.17f, 36.17f, 0.0f, true, true, -72.33f, 0.0f) } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(764.66f, 441.17f) lineToRelative(-28.83f, -16.64f) arcToRelative(0.75f, 0.75f, 0.0f, false, false, -1.12f, 0.65f) verticalLineToRelative(33.29f) arcToRelative(0.75f, 0.75f, 0.0f, false, false, 1.12f, 0.65f) lineTo(764.66f, 442.46f) arcToRelative(0.75f, 0.75f, 0.0f, false, false, 0.0f, -1.3f) lineToRelative(-28.83f, -16.64f) arcToRelative(0.75f, 0.75f, 0.0f, false, false, -1.12f, 0.65f) verticalLineToRelative(33.29f) arcToRelative(0.75f, 0.75f, 0.0f, false, false, 1.12f, 0.65f) lineTo(764.66f, 442.46f) arcTo(0.75f, 0.75f, 0.0f, false, false, 764.66f, 441.17f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceVariant), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(701.92f, 592.03f) arcToRelative(15.34f, 15.34f, 0.0f, false, true, -15.26f, 15.42f) lineTo(15.26f, 607.45f) arcToRelative(15.34f, 15.34f, 0.0f, false, true, -15.26f, -15.42f) lineTo(0.0f, 133.42f) arcToRelative(15.34f, 15.34f, 0.0f, false, true, 15.26f, -15.42f) lineTo(686.66f, 118.0f) arcToRelative(15.34f, 15.34f, 0.0f, false, true, 15.26f, 15.42f) verticalLineToRelative(0.0f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(694.0f, 584.52f) arcToRelative(14.91f, 14.91f, 0.0f, false, true, -14.91f, 14.91f) lineTo(23.29f, 599.43f) arcToRelative(14.91f, 14.91f, 0.0f, false, true, -14.91f, -14.91f) lineTo(8.38f, 141.14f) arcToRelative(14.91f, 14.91f, 0.0f, false, true, 14.91f, -14.91f) lineTo(679.12f, 126.23f) arcToRelative(14.91f, 14.91f, 0.0f, false, true, 14.88f, 14.91f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceVariant), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(43.43f, 145.48f) horizontalLineToRelative(616.22f) arcToRelative(8.85f, 8.85f, 0.0f, false, true, 8.85f, 8.85f) verticalLineTo(569.61f) arcToRelative(8.85f, 8.85f, 0.0f, false, true, -8.85f, 8.85f) horizontalLineToRelative(-616.22f) arcToRelative(8.85f, 8.85f, 0.0f, false, true, -8.85f, -8.85f) verticalLineTo(154.33f) arcToRelative(8.85f, 8.85f, 0.0f, false, true, 8.85f, -8.85f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(51.23f, 155.44f) horizontalLineToRelative(599.95f) arcToRelative(8.85f, 8.85f, 0.0f, false, true, 8.85f, 8.85f) verticalLineTo(557.41f) arcToRelative(8.85f, 8.85f, 0.0f, false, true, -8.85f, 8.85f) horizontalLineToRelative(-599.95f) arcToRelative(8.85f, 8.85f, 0.0f, false, true, -8.85f, -8.85f) verticalLineTo(164.29f) arcTo(8.85f, 8.85f, 0.0f, false, true, 51.23f, 155.44f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.inversePrimary), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(474.84f, 428.53f) arcToRelative(16.87f, 16.87f, 0.0f, false, true, -6.85f, -1.46f) arcToRelative(8.31f, 8.31f, 0.0f, false, true, -1.43f, -0.81f) lineToRelative(-43.6f, -30.69f) horizontalLineToRelative(0.0f) arcToRelative(16.87f, 16.87f, 0.0f, false, true, -7.16f, -13.79f) lineTo(415.79f, 339.92f) arcToRelative(16.87f, 16.87f, 0.0f, false, true, 7.16f, -13.79f) lineToRelative(43.6f, -30.69f) arcToRelative(8.31f, 8.31f, 0.0f, false, true, 1.43f, -0.81f) arcTo(16.87f, 16.87f, 0.0f, false, true, 491.71f, 310.04f) lineTo(491.71f, 411.66f) arcToRelative(16.87f, 16.87f, 0.0f, false, true, -16.87f, 16.87f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.inversePrimary), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(377.26f, 436.76f) lineTo(255.65f, 436.76f) curveToRelative(-24.81f, -0.02f, -44.92f, -16.06f, -44.94f, -35.85f) lineTo(210.71f, 320.79f) curveToRelative(0.03f, -19.79f, 20.13f, -35.83f, 44.94f, -35.85f) lineTo(377.57f, 284.94f) curveToRelative(24.63f, 0.02f, 44.6f, 15.95f, 44.62f, 35.59f) verticalLineToRelative(80.38f) curveTo(422.17f, 420.7f, 402.07f, 436.74f, 377.26f, 436.76f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.outline), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(593.88f, 504.81f) moveToRelative(-36.17f, 0.0f) arcToRelative(36.17f, 36.17f, 0.0f, true, true, 72.33f, 0.0f) arcToRelative(36.17f, 36.17f, 0.0f, true, true, -72.33f, 0.0f) } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(612.66f, 504.17f) lineToRelative(-28.83f, -16.64f) arcToRelative(0.75f, 0.75f, 0.0f, false, false, -1.12f, 0.65f) verticalLineToRelative(33.29f) arcToRelative(0.75f, 0.75f, 0.0f, false, false, 1.12f, 0.65f) lineTo(612.66f, 505.46f) arcToRelative(0.75f, 0.75f, 0.0f, false, false, 0.0f, -1.3f) lineToRelative(-28.83f, -16.64f) arcToRelative(0.75f, 0.75f, 0.0f, false, false, -1.12f, 0.65f) verticalLineToRelative(33.29f) arcToRelative(0.75f, 0.75f, 0.0f, false, false, 1.12f, 0.65f) lineTo(612.66f, 505.46f) arcTo(0.75f, 0.75f, 0.0f, false, false, 612.66f, 504.17f) close() } } .build() } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/svg/drawablevectors/VideoSteaming.kt ================================================ package com.junkfood.seal.ui.svg.drawablevectors import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathFillType.Companion.NonZero import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap.Companion.Butt import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp import com.junkfood.seal.ui.svg.DynamicColorImageVectors @Composable fun DynamicColorImageVectors.videoSteaming(): ImageVector { return Builder( name = "VideoSteaming", defaultWidth = 766.0.dp, defaultHeight = 663.78.dp, viewportWidth = 766.0f, viewportHeight = 663.78f, ) .apply { path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainerHighest), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(75.871f, 614.538f) curveToRelative(-6.979f, 23.313f, 3.852f, 47.148f, 3.852f, 47.148f) reflectiveCurveToRelative(22.147f, -13.963f, 29.126f, -37.276f) reflectiveCurveToRelative(-3.852f, -47.148f, -3.852f, -47.148f) reflectiveCurveTo(82.85f, 591.225f, 75.871f, 614.538f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainerHighest), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(86.467f, 615.169f) curveToRelative(5.874f, 23.616f, -6.068f, 46.915f, -6.068f, 46.915f) reflectiveCurveToRelative(-21.465f, -14.99f, -27.339f, -38.606f) reflectiveCurveToRelative(6.068f, -46.915f, 6.068f, -46.915f) reflectiveCurveTo(80.594f, 591.553f, 86.467f, 615.169f) close() } path( fill = SolidColor(Color(0xFFffb8b8)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(435.535f, 638.619f) lineToRelative(4.442f, -11.427f) lineToRelative(-41.96f, -22.573f) lineToRelative(-6.556f, 16.865f) lineToRelative(44.074f, 17.135f) close() } path( fill = SolidColor(Color(0xFF2f2e41)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(459.337f, 608.81f) arcToRelative(15.379f, 15.379f, 0.0f, false, false, -19.915f, 8.766f) lineToRelative(-2.997f, 7.703f) lineToRelative(-4.62f, 11.89f) lineToRelative(-1.129f, 2.915f) lineToRelative(14.806f, 5.76f) lineToRelative(14.321f, -36.849f) close() } path( fill = SolidColor(Color(0xFF2f2e41)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(431.806f, 616.628f) lineToRelative(-1.932f, 5.124f) lineToRelative(-4.822f, 12.788f) lineToRelative(-0.116f, 0.32f) curveToRelative(-12.285f, 1.578f, -25.752f, -1.516f, -38.209f, -6.323f) arcToRelative(155.312f, 155.312f, 0.0f, false, true, -14.624f, -6.526f) curveToRelative(-7.044f, -3.559f, -13.352f, -7.316f, -18.383f, -10.529f) curveToRelative(-7.576f, -4.862f, -12.26f, -8.513f, -12.26f, -8.513f) reflectiveCurveToRelative(-1.238f, 1.118f, -3.477f, 3.036f) curveToRelative(-3.002f, 2.568f, -7.798f, 6.578f, -13.873f, 11.299f) quadToRelative(-3.476f, 2.72f, -7.465f, 5.69f) curveToRelative(-19.601f, 14.539f, -44.756f, -24.256f, -44.756f, -24.256f) reflectiveCurveToRelative(7.075f, -4.504f, 8.923f, -5.379f) curveToRelative(5.951f, -2.816f, 19.932f, -9.459f, 31.994f, -15.363f) curveToRelative(6.214f, -3.046f, 11.912f, -5.903f, 15.737f, -7.932f) curveToRelative(13.546f, -7.212f, 30.384f, 6.435f, 30.384f, 6.435f) close() } path( fill = SolidColor(Color(0xFF000000)), stroke = null, fillAlpha = 0.14f, strokeAlpha = 0.14f, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(431.806f, 616.628f) lineToRelative(-1.932f, 5.124f) lineToRelative(-4.822f, 12.788f) lineToRelative(-0.116f, 0.32f) curveToRelative(-12.285f, 1.578f, -25.752f, -1.516f, -38.209f, -6.323f) arcToRelative(155.312f, 155.312f, 0.0f, false, true, -14.624f, -6.526f) curveToRelative(-7.044f, -3.559f, -13.352f, -7.316f, -18.383f, -10.529f) curveToRelative(-7.576f, -4.862f, -12.26f, -8.513f, -12.26f, -8.513f) reflectiveCurveToRelative(-1.238f, 1.118f, -3.477f, 3.036f) curveToRelative(-3.002f, 2.568f, -7.798f, 6.578f, -13.873f, 11.299f) quadToRelative(-3.476f, 2.72f, -7.465f, 5.69f) curveToRelative(-19.601f, 14.539f, -44.756f, -24.256f, -44.756f, -24.256f) reflectiveCurveToRelative(7.075f, -4.504f, 8.923f, -5.379f) curveToRelative(5.951f, -2.816f, 19.932f, -9.459f, 31.994f, -15.363f) curveToRelative(6.214f, -3.046f, 11.912f, -5.903f, 15.737f, -7.932f) curveToRelative(13.546f, -7.212f, 30.384f, 6.435f, 30.384f, 6.435f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.tertiaryContainer), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(183.579f, 172.312f) moveToRelative(-172.312f, 0.0f) arcToRelative(172.312f, 172.312f, 0.0f, true, true, 344.623f, 0.0f) arcToRelative(172.312f, 172.312f, 0.0f, true, true, -344.623f, 0.0f) } path( fill = SolidColor(Color(0xFF000000)), stroke = null, fillAlpha = 0.2f, strokeAlpha = 0.2f, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(56.349f, 57.221f) arcTo(172.325f, 172.325f, 0.0f, false, false, 343.472f, 239.591f) arcTo(172.327f, 172.327f, 0.0f, true, true, 56.349f, 57.221f) close() } path( fill = SolidColor(Color(0xFF3f3d56)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(184.052f, 172.311f) lineToRelative(0.474f, 0.0f) lineToRelative(8.521f, 488.532f) lineToRelative(-17.989f, 0.0f) lineToRelative(8.994f, -488.532f) close() } path( fill = SolidColor(Color(0xFF3f3d56)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(153.557f, 401.412f) lineToRelative(3.97f, -7.54f) lineToRelative(28.484f, 14.996f) lineToRelative(-3.97f, 7.54f) close() } path( fill = SolidColor(Color(0xFF3f3d56)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(765.0f, 662.788f) lineTo(1.0f, 662.788f) arcToRelative(1.0f, 1.0f, 0.0f, false, true, 0.0f, -2.0f) lineTo(765.0f, 660.788f) arcToRelative(1.0f, 1.0f, 0.0f, false, true, 0.0f, 2.0f) close() } path( fill = SolidColor(Color(0xFF2f2e41)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(235.746f, 509.077f) horizontalLineToRelative(-58.0f) arcToRelative(4.505f, 4.505f, 0.0f, false, true, -4.5f, -4.5f) verticalLineToRelative(-25.0f) arcToRelative(33.5f, 33.5f, 0.0f, false, true, 67.0f, 0.0f) verticalLineToRelative(25.0f) arcTo(4.505f, 4.505f, 0.0f, false, true, 235.746f, 509.077f) close() } path( fill = SolidColor(Color(0xFFffb8b8)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(290.904f, 540.478f) arcToRelative(9.377f, 9.377f, 0.0f, false, false, -7.186f, 12.454f) lineToRelative(-15.772f, 14.506f) lineToRelative(6.478f, 11.734f) lineToRelative(21.985f, -20.798f) arcToRelative(9.428f, 9.428f, 0.0f, false, false, -5.505f, -17.897f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceVariant), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(243.448f, 606.43f) quadToRelative(-1.069f, 0.0f, -2.14f, -0.092f) arcTo(25.199f, 25.199f, 0.0f, false, true, 221.01f, 592.782f) lineToRelative(-25.267f, -48.633f) arcToRelative(13.954f, 13.954f, 0.0f, false, true, 24.13f, -13.965f) lineToRelative(23.311f, 52.304f) lineToRelative(31.719f, -25.772f) lineToRelative(18.366f, 7.149f) lineToRelative(-31.016f, 34.242f) arcTo(25.485f, 25.485f, 0.0f, false, true, 243.448f, 606.43f) close() } path( fill = SolidColor(Color(0xFF3f3d56)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(336.913f, 531.285f) lineToRelative(-28.102f, -28.102f) arcToRelative(5.242f, 5.242f, 0.0f, false, false, -7.413f, 0.0f) lineToRelative(-25.28f, 25.28f) arcToRelative(5.242f, 5.242f, 0.0f, false, false, 0.0f, 7.413f) lineToRelative(26.256f, 26.256f) arcToRelative(5.242f, 5.242f, 0.0f, false, false, 7.133f, 0.26f) lineToRelative(27.125f, -23.435f) arcToRelative(5.242f, 5.242f, 0.0f, false, false, 0.54f, -7.393f) quadTo(337.049f, 531.42f, 336.913f, 531.285f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainerHighest), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(332.935f, 536.213f) lineToRelative(-28.926f, -28.925f) lineToRelative(-23.871f, 23.871f) lineToRelative(27.803f, 27.802f) lineToRelative(24.994f, -22.748f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainerHighest), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(312.209f, 533.077f) moveToRelative(-0.87f, 0.0f) arcToRelative(0.87f, 0.87f, 0.0f, true, true, 1.74f, 0.0f) arcToRelative(0.87f, 0.87f, 0.0f, true, true, -1.74f, 0.0f) } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainerHighest), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(312.584f, 530.935f) moveToRelative(-0.435f, 0.0f) arcToRelative(0.435f, 0.435f, 0.0f, true, true, 0.87f, 0.0f) arcToRelative(0.435f, 0.435f, 0.0f, true, true, -0.87f, 0.0f) } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainerHighest), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(306.328f, 524.277f) moveToRelative(-0.435f, 0.0f) arcToRelative(0.435f, 0.435f, 0.0f, true, true, 0.87f, 0.0f) arcToRelative(0.435f, 0.435f, 0.0f, true, true, -0.87f, 0.0f) } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceContainerHighest), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(305.953f, 526.419f) moveToRelative(-0.87f, 0.0f) arcToRelative(0.87f, 0.87f, 0.0f, true, true, 1.74f, 0.0f) arcToRelative(0.87f, 0.87f, 0.0f, true, true, -1.74f, 0.0f) } path( fill = SolidColor(Color(0xFFffb8b8)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(431.99f, 654.69f) lineToRelative(5.307f, -11.051f) lineToRelative(-40.101f, -25.73f) lineToRelative(-7.833f, 16.311f) lineToRelative(42.627f, 20.47f) close() } path( fill = SolidColor(Color(0xFF2f2e41)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(458.012f, 626.798f) arcToRelative(15.379f, 15.379f, 0.0f, false, false, -20.53f, 7.21f) lineToRelative(-3.58f, 7.45f) lineToRelative(-5.52f, 11.5f) lineToRelative(-1.35f, 2.82f) lineToRelative(14.32f, 6.88f) lineToRelative(17.11f, -35.64f) close() } path( fill = SolidColor(Color(0xFF2f2e41)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(429.962f, 632.478f) lineToRelative(-2.32f, 4.96f) lineToRelative(-5.79f, 12.38f) lineToRelative(-0.14f, 0.31f) curveToRelative(-12.37f, 0.63f, -25.56f, -3.49f, -37.61f, -9.24f) arcToRelative(155.312f, 155.312f, 0.0f, false, true, -14.08f, -7.63f) curveToRelative(-6.75f, -4.09f, -12.75f, -8.32f, -17.52f, -11.91f) curveToRelative(-7.18f, -5.43f, -11.57f, -9.43f, -11.57f, -9.43f) reflectiveCurveToRelative(-1.32f, 1.02f, -3.7f, 2.76f) curveToRelative(-3.19f, 2.33f, -8.28f, 5.96f, -14.7f, 10.2f) quadToRelative(-3.675f, 2.445f, -7.88f, 5.1f) curveToRelative(-20.66f, 12.99f, -50.04f, 28.81f, -75.03f, 32.15f) curveToRelative(-43.89f, 5.87f, -33.56f, -52.7f, -33.56f, -52.7f) lineToRelative(34.93f, -18.14f) lineToRelative(14.83f, 3.01f) lineToRelative(16.51f, 3.34f) lineToRelative(5.85f, 1.19f) reflectiveCurveToRelative(1.11f, -0.42f, 3.02f, -1.15f) curveToRelative(6.15f, -2.35f, 20.6f, -7.9f, 33.08f, -12.86f) curveToRelative(6.43f, -2.56f, 12.33f, -4.97f, 16.3f, -6.7f) curveToRelative(14.06f, -6.15f, 29.8f, 8.75f, 29.8f, 8.75f) close() } path( fill = SolidColor(Color(0xFFffb8b8)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(142.145f, 663.526f) arcToRelative(9.377f, 9.377f, 0.0f, false, false, 6.532f, -12.809f) lineToRelative(15.0f, -15.302f) lineToRelative(-7.076f, -11.384f) lineToRelative(-20.88f, 21.907f) arcToRelative(9.428f, 9.428f, 0.0f, false, false, 6.423f, 17.588f) close() } path( fill = SolidColor(Color(0xFFffb8b8)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(214.958f, 486.807f) moveToRelative(-23.386f, 0.0f) arcToRelative(23.386f, 23.386f, 0.0f, true, true, 46.772f, 0.0f) arcToRelative(23.386f, 23.386f, 0.0f, true, true, -46.772f, 0.0f) } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceVariant), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(184.533f, 551.724f) lineTo(204.905f, 609.48f) lineToRelative(-0.122f, 0.202f) curveToRelative(-2.833f, 4.684f, -3.76f, 8.462f, -2.678f, 10.926f) arcToRelative(4.761f, 4.761f, 0.0f, false, false, 3.019f, 2.604f) lineToRelative(48.209f, -33.697f) lineToRelative(-1.688f, -13.505f) lineToRelative(0.094f, -0.151f) curveToRelative(4.66f, -7.456f, 6.168f, -14.208f, 4.485f, -20.067f) curveToRelative(-2.184f, -7.604f, -9.125f, -11.056f, -9.195f, -11.09f) lineToRelative(-0.168f, -0.132f) lineTo(222.815f, 515.304f) lineTo(190.673f, 521.014f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.surfaceVariant), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(153.553f, 653.099f) lineTo(139.906f, 636.302f) lineToRelative(32.899f, -54.31f) lineToRelative(15.339f, -47.413f) lineToRelative(0.476f, 0.154f) lineToRelative(-0.476f, -0.154f) arcToRelative(19.047f, 19.047f, 0.0f, true, true, 33.964f, 16.437f) lineToRelative(-28.16f, 42.189f) close() } path( fill = SolidColor(Color(0xFF2f2e41)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(248.171f, 482.077f) lineTo(212.706f, 482.077f) lineToRelative(-0.364f, -5.092f) lineToRelative(-1.818f, 5.092f) horizontalLineToRelative(-5.461f) lineToRelative(-0.721f, -10.092f) lineToRelative(-3.604f, 10.092f) lineTo(190.171f, 482.077f) verticalLineToRelative(-0.5f) arcToRelative(26.53f, 26.53f, 0.0f, false, true, 26.5f, -26.5f) horizontalLineToRelative(5.0f) arcToRelative(26.53f, 26.53f, 0.0f, false, true, 26.5f, 26.5f) close() } path( fill = SolidColor(Color(0xFF2f2e41)), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(212.415f, 513.743f) arcToRelative(4.597f, 4.597f, 0.0f, false, true, -0.796f, -0.07f) lineToRelative(-25.969f, -4.582f) lineTo(185.65f, 466.171f) lineTo(214.237f, 466.171f) lineToRelative(-0.708f, 0.825f) curveToRelative(-9.847f, 11.484f, -2.428f, 30.106f, 2.87f, 40.185f) arcToRelative(4.433f, 4.433f, 0.0f, false, true, -0.352f, 4.707f) arcTo(4.482f, 4.482f, 0.0f, false, true, 212.415f, 513.743f) close() } path( fill = SolidColor(MaterialTheme.colorScheme.primary), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(588.139f, 324.689f) moveToRelative(-114.0f, 0.0f) arcToRelative(114.0f, 114.0f, 0.0f, true, true, 228.0f, 0.0f) arcToRelative(114.0f, 114.0f, 0.0f, true, true, -228.0f, 0.0f) } path( fill = SolidColor(MaterialTheme.colorScheme.onPrimary), stroke = null, strokeLineWidth = 0.0f, strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, pathFillType = NonZero, ) { moveTo(558.992f, 280.378f) verticalLineToRelative(96.991f) arcToRelative(2.497f, 2.497f, 0.0f, false, false, 3.74f, 2.302f) lineToRelative(76.883f, -48.496f) arcToRelative(2.748f, 2.748f, 0.0f, false, false, 0.0f, -4.571f) lineToRelative(-76.883f, -48.496f) arcToRelative(2.474f, 2.474f, 0.0f, false, false, -3.74f, 2.27f) close() } } .build() } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/theme/ColorScheme.kt ================================================ package com.junkfood.seal.ui.theme import androidx.compose.material3.ColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.Stable import androidx.compose.ui.graphics.Color import com.junkfood.seal.ui.common.LocalDarkTheme import com.junkfood.seal.ui.common.LocalFixedColorRoles import com.kyant.monet.TonalPalettes import com.kyant.monet.TonalPalettes.Companion.toTonalPalettes import io.material.hct.Hct @Composable fun Number.autoDark(isDarkTheme: Boolean = LocalDarkTheme.current.isDarkTheme()): Double = if (!isDarkTheme) this.toDouble() else when (this.toDouble()) { 6.0 -> 98.0 10.0 -> 99.0 20.0 -> 95.0 25.0 -> 90.0 30.0 -> 90.0 40.0 -> 80.0 50.0 -> 60.0 60.0 -> 50.0 70.0 -> 40.0 80.0 -> 40.0 90.0 -> 30.0 95.0 -> 20.0 98.0 -> 10.0 99.0 -> 10.0 100.0 -> 20.0 else -> this.toDouble() } @Deprecated( message = "Deprecated", replaceWith = ReplaceWith( "LocalFixedColorRoles.current", imports = arrayOf("com.junkfood.seal.ui.common.LocalFixedColorRoles"), ), ) object FixedAccentColors { val primaryFixed: Color @Composable get() = LocalFixedColorRoles.current.primaryFixed val primaryFixedDim: Color @Composable get() = LocalFixedColorRoles.current.primaryFixedDim val onPrimaryFixed: Color @Composable get() = LocalFixedColorRoles.current.onPrimaryFixed val onPrimaryFixedVariant: Color @Composable get() = LocalFixedColorRoles.current.onPrimaryFixedVariant val secondaryFixed: Color @Composable get() = LocalFixedColorRoles.current.secondaryFixed val secondaryFixedDim: Color @Composable get() = LocalFixedColorRoles.current.secondaryFixedDim val onSecondaryFixed: Color @Composable get() = LocalFixedColorRoles.current.onSecondaryFixed val onSecondaryFixedVariant: Color @Composable get() = LocalFixedColorRoles.current.onSecondaryFixedVariant val tertiaryFixed: Color @Composable get() = LocalFixedColorRoles.current.tertiaryFixed val tertiaryFixedDim: Color @Composable get() = LocalFixedColorRoles.current.tertiaryFixedDim val onTertiaryFixed: Color @Composable get() = LocalFixedColorRoles.current.onTertiaryFixed val onTertiaryFixedVariant: Color @Composable get() = LocalFixedColorRoles.current.onTertiaryFixedVariant } @Immutable data class FixedColorRoles( val primaryFixed: Color, val primaryFixedDim: Color, val onPrimaryFixed: Color, val onPrimaryFixedVariant: Color, val secondaryFixed: Color, val secondaryFixedDim: Color, val onSecondaryFixed: Color, val onSecondaryFixedVariant: Color, val tertiaryFixed: Color, val tertiaryFixedDim: Color, val onTertiaryFixed: Color, val onTertiaryFixedVariant: Color, ) { companion object { internal val unspecified = FixedColorRoles( primaryFixed = Color.Unspecified, primaryFixedDim = Color.Unspecified, onPrimaryFixed = Color.Unspecified, onPrimaryFixedVariant = Color.Unspecified, secondaryFixed = Color.Unspecified, secondaryFixedDim = Color.Unspecified, onSecondaryFixed = Color.Unspecified, onSecondaryFixedVariant = Color.Unspecified, tertiaryFixed = Color.Unspecified, tertiaryFixedDim = Color.Unspecified, onTertiaryFixed = Color.Unspecified, onTertiaryFixedVariant = Color.Unspecified, ) @Stable internal fun fromTonalPalettes(palettes: TonalPalettes): FixedColorRoles { return with(palettes) { FixedColorRoles( primaryFixed = accent1(90.toDouble()), primaryFixedDim = accent1(80.toDouble()), onPrimaryFixed = accent1(10.toDouble()), onPrimaryFixedVariant = accent1(30.toDouble()), secondaryFixed = accent2(90.toDouble()), secondaryFixedDim = accent2(80.toDouble()), onSecondaryFixed = accent2(10.toDouble()), onSecondaryFixedVariant = accent2(30.toDouble()), tertiaryFixed = accent3(90.toDouble()), tertiaryFixedDim = accent3(80.toDouble()), onTertiaryFixed = accent3(10.toDouble()), onTertiaryFixedVariant = accent3(30.toDouble()), ) } } @Stable internal fun fromColorSchemes( lightColors: ColorScheme, darkColors: ColorScheme, ): FixedColorRoles { return FixedColorRoles( primaryFixed = lightColors.primaryContainer, onPrimaryFixed = lightColors.onPrimaryContainer, onPrimaryFixedVariant = darkColors.primaryContainer, secondaryFixed = lightColors.secondaryContainer, onSecondaryFixed = lightColors.onSecondaryContainer, onSecondaryFixedVariant = darkColors.secondaryContainer, tertiaryFixed = lightColors.tertiaryContainer, onTertiaryFixed = lightColors.onTertiaryContainer, onTertiaryFixedVariant = darkColors.tertiaryContainer, primaryFixedDim = darkColors.primary, secondaryFixedDim = darkColors.secondary, tertiaryFixedDim = darkColors.tertiary, ) } } } const val DEFAULT_SEED_COLOR = 0xa3d48d /** * @return a [Color] generated using [Hct] algorithm, harmonized with `primary` color * @receiver Seed number used for generating color */ @Composable @ReadOnlyComposable fun Int.generateLabelColor(): Color = Color(Hct.from(hue = (this % 360).toDouble(), chroma = 36.0, tone = 80.0).toInt()) .harmonizeWithPrimary() /** * @return a [Color] generated using [Hct] algorithm, harmonized with `primary` color * @receiver Seed number used for generating color */ @Composable @ReadOnlyComposable fun Int.generateOnLabelColor(): Color = Color(Hct.from(hue = (this % 360).toDouble(), chroma = 36.0, tone = 20.0).toInt()) .harmonizeWithPrimary() val ErrorTonalPalettes = Color.Red.toTonalPalettes() ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/theme/Shape.kt ================================================ package com.junkfood.seal.ui.theme import androidx.compose.material3.Shapes val Shapes = Shapes() ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/theme/Theme.kt ================================================ package com.junkfood.seal.ui.theme import android.os.Build import android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.text.style.TextDirection import com.google.android.material.color.MaterialColors import com.junkfood.seal.ui.common.LocalFixedColorRoles import com.kyant.monet.LocalTonalPalettes import com.kyant.monet.dynamicColorScheme fun Color.applyOpacity(enabled: Boolean): Color { return if (enabled) this else this.copy(alpha = 0.62f) } @Composable @ReadOnlyComposable fun Color.harmonizeWith(other: Color) = Color(MaterialColors.harmonize(this.toArgb(), other.toArgb())) @Composable @ReadOnlyComposable fun Color.harmonizeWithPrimary(): Color = this.harmonizeWith(other = MaterialTheme.colorScheme.primary) @Composable fun SealTheme( darkTheme: Boolean = isSystemInDarkTheme(), isHighContrastModeEnabled: Boolean = false, content: @Composable () -> Unit, ) { val view = LocalView.current LaunchedEffect(darkTheme) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (darkTheme) { view.windowInsetsController?.setSystemBarsAppearance( 0, APPEARANCE_LIGHT_STATUS_BARS, ) } else { view.windowInsetsController?.setSystemBarsAppearance( APPEARANCE_LIGHT_STATUS_BARS, APPEARANCE_LIGHT_STATUS_BARS, ) } } } val colorScheme = dynamicColorScheme(!darkTheme).run { if (isHighContrastModeEnabled && darkTheme) copy( surface = Color.Black, background = Color.Black, surfaceContainerLowest = Color.Black, surfaceContainerLow = surfaceContainerLowest, surfaceContainer = surfaceContainerLow, surfaceContainerHigh = surfaceContainerLow, surfaceContainerHighest = surfaceContainer, ) else this } val textStyle = LocalTextStyle.current.copy( lineBreak = LineBreak.Paragraph, textDirection = TextDirection.Content, ) val tonalPalettes = LocalTonalPalettes.current CompositionLocalProvider( LocalFixedColorRoles provides FixedColorRoles.fromTonalPalettes(tonalPalettes), LocalTextStyle provides textStyle, ) { MaterialTheme( colorScheme = colorScheme, typography = Typography, shapes = Shapes, content = content, ) } } @Composable @Deprecated("Use SealTheme instead", replaceWith = ReplaceWith("SealTheme(content)")) fun PreviewThemeLight(content: @Composable () -> Unit) { SealTheme(darkTheme = false, content = content) } ================================================ FILE: app/src/main/java/com/junkfood/seal/ui/theme/Type.kt ================================================ @file:OptIn(ExperimentalTextApi::class, ExperimentalTextApi::class, ExperimentalTextApi::class) package com.junkfood.seal.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.text.style.TextDirection val Typography = Typography().run { copy( bodyLarge = bodyLarge.applyLinebreak().applyTextDirection(), bodyMedium = bodyMedium.applyLinebreak().applyTextDirection(), bodySmall = bodySmall.applyLinebreak().applyTextDirection(), titleLarge = titleLarge.applyTextDirection(), titleMedium = titleMedium.applyTextDirection(), titleSmall = titleSmall.applyTextDirection(), headlineSmall = headlineSmall.applyTextDirection(), headlineMedium = headlineMedium.applyTextDirection(), headlineLarge = headlineLarge.applyTextDirection(), displaySmall = displaySmall.applyTextDirection(), displayMedium = displayMedium.applyTextDirection(), displayLarge = displayLarge.applyTextDirection(), labelLarge = labelLarge.applyTextDirection(), labelMedium = labelMedium.applyTextDirection(), labelSmall = labelSmall.applyTextDirection(), ) } private fun TextStyle.applyLinebreak(): TextStyle = this.copy(lineBreak = LineBreak.Paragraph) private fun TextStyle.applyTextDirection(): TextStyle = this.copy(textDirection = TextDirection.Content) ================================================ FILE: app/src/main/java/com/junkfood/seal/util/DatabaseUtil.kt ================================================ package com.junkfood.seal.util import androidx.room.Room import com.junkfood.seal.App.Companion.applicationScope import com.junkfood.seal.App.Companion.context import com.junkfood.seal.database.AppDatabase import com.junkfood.seal.database.backup.Backup import com.junkfood.seal.database.backup.BackupUtil.BackupType import com.junkfood.seal.database.backup.BackupUtil.decodeToBackup import com.junkfood.seal.database.objects.CommandTemplate import com.junkfood.seal.database.objects.CookieProfile import com.junkfood.seal.database.objects.DownloadedVideoInfo import com.junkfood.seal.database.objects.OptionShortcut import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch object DatabaseUtil { private const val DATABASE_NAME = "app_database" private val db = Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME).build() private val dao = db.videoInfoDao() fun insertInfo(vararg infoList: DownloadedVideoInfo) { applicationScope.launch(Dispatchers.IO) { infoList.forEach { dao.insertInfoDistinctByPath(it) } } } init { applicationScope.launch { getTemplateFlow().collect { if (it.isEmpty()) PreferenceUtil.initializeTemplateSample() } } } fun getDownloadHistoryFlow() = dao.getDownloadHistoryFlow() private suspend fun getDownloadHistory() = dao.getDownloadHistory() fun getTemplateFlow() = dao.getTemplateFlow() fun getCookiesFlow() = dao.getCookieProfileFlow() fun getShortcuts() = dao.getOptionShortcuts() suspend fun deleteShortcut(shortcut: OptionShortcut) = dao.deleteShortcut(shortcut) suspend fun insertShortcut(shortcut: OptionShortcut) = dao.insertShortcut(shortcut) suspend fun getCookieById(id: Int) = dao.getCookieById(id) suspend fun deleteCookieProfile(profile: CookieProfile) = dao.deleteCookieProfile(profile) suspend fun insertCookieProfile(profile: CookieProfile) = dao.insertCookieProfile(profile) suspend fun updateCookieProfile(profile: CookieProfile) = dao.updateCookieProfile(profile) suspend fun getTemplateList() = dao.getTemplateList() suspend fun getShortcutList() = dao.getShortcutList() suspend fun deleteInfoList(infoList: List, deleteFile: Boolean = false) { dao.deleteInfoList(infoList) infoList.forEach { info -> if (deleteFile) FileUtil.deleteFile(info.videoPath) } } suspend fun getInfoById(id: Int): DownloadedVideoInfo = dao.getInfoById(id) suspend fun deleteInfoById(id: Int) = dao.deleteInfoById(id) suspend fun insertTemplate(commandTemplate: CommandTemplate) = dao.insertTemplate(commandTemplate) suspend fun updateTemplate(commandTemplate: CommandTemplate) { dao.updateTemplate(commandTemplate) } suspend fun importBackup(backup: Backup, types: Set): Int { var cnt = 0 backup.run { if (types.contains(BackupType.DownloadHistory)) { val itemList = getDownloadHistory() if (!downloadHistory.isNullOrEmpty()) { dao.insertAll( downloadHistory .filterNot { itemList.contains(it) } .map { it.copy(id = 0) } .also { cnt += it.size } ) } } if (types.contains(BackupType.CommandTemplate)) { if (templates != null) { val templateList = getTemplateList() dao.importTemplates( templateList .filterNot { templateList.contains(it) } .map { it.copy(id = 0) } .also { cnt += it.size } ) } } if (types.contains(BackupType.CommandShortcut)) { val shortcutList = getShortcutList() if (shortcuts != null) { dao.insertAllShortcuts( shortcuts .filterNot { shortcutList.contains(it) } .map { it.copy(id = 0) } .also { cnt += it.size } ) } } } return cnt } suspend fun importTemplatesFromJson(json: String): Int { json .decodeToBackup() .onSuccess { backup -> return importBackup( backup = backup, types = setOf(BackupType.CommandTemplate, BackupType.CommandShortcut), ) } .onFailure { it.printStackTrace() } return 0 } suspend fun deleteTemplateById(id: Int) = dao.deleteTemplateById(id) suspend fun deleteTemplates(templates: List) = dao.deleteTemplates(templates) private const val TAG = "DatabaseUtil" } ================================================ FILE: app/src/main/java/com/junkfood/seal/util/DateTimeUtil.kt ================================================ package com.junkfood.seal.util import android.os.Build import java.text.DateFormat import java.text.SimpleDateFormat import java.time.Instant import java.util.Date import java.util.Locale private val SimpleDateFormat by lazy { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) } fun Long.toLocalizedString(locale: Locale = Locale.getDefault()): String { return if (Build.VERSION.SDK_INT >= 26) { DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT, locale) .format(Date.from(Instant.ofEpochMilli(this))) } else { SimpleDateFormat.format(Date(this)) } } ================================================ FILE: app/src/main/java/com/junkfood/seal/util/DownloadUtil.kt ================================================ package com.junkfood.seal.util import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase.OPEN_READONLY import android.media.MediaCodecList import android.os.Build import android.util.Log import android.webkit.CookieManager import androidx.annotation.CheckResult import com.junkfood.seal.App import com.junkfood.seal.App.Companion.audioDownloadDir import com.junkfood.seal.App.Companion.context import com.junkfood.seal.App.Companion.videoDownloadDir import com.junkfood.seal.Downloader import com.junkfood.seal.Downloader.onProcessEnded import com.junkfood.seal.Downloader.onProcessStarted import com.junkfood.seal.Downloader.onTaskEnded import com.junkfood.seal.Downloader.onTaskError import com.junkfood.seal.Downloader.onTaskStarted import com.junkfood.seal.Downloader.toNotificationId import com.junkfood.seal.R import com.junkfood.seal.database.objects.CommandTemplate import com.junkfood.seal.database.objects.DownloadedVideoInfo import com.junkfood.seal.ui.page.settings.network.Cookie import com.junkfood.seal.util.FileUtil.getArchiveFile import com.junkfood.seal.util.FileUtil.getConfigFile import com.junkfood.seal.util.FileUtil.getCookiesFile import com.junkfood.seal.util.FileUtil.getExternalTempDir import com.junkfood.seal.util.FileUtil.getFileName import com.junkfood.seal.util.FileUtil.getSdcardTempDir import com.junkfood.seal.util.FileUtil.moveFilesToSdcard import com.junkfood.seal.util.PreferenceUtil.COOKIE_HEADER import com.junkfood.seal.util.PreferenceUtil.getBoolean import com.junkfood.seal.util.PreferenceUtil.getInt import com.junkfood.seal.util.PreferenceUtil.getString import com.junkfood.seal.util.PreferenceUtil.updateBoolean import com.yausername.youtubedl_android.YoutubeDL import com.yausername.youtubedl_android.YoutubeDLException import com.yausername.youtubedl_android.YoutubeDLRequest import com.yausername.youtubedl_android.YoutubeDLResponse import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import java.util.Locale object DownloadUtil { object CookieScheme { const val NAME = "name" const val VALUE = "value" const val SECURE = "is_secure" const val EXPIRY = "expires_utc" const val HOST = "host_key" const val PATH = "path" } private val jsonFormat = Json { ignoreUnknownKeys = true } private const val TAG = "DownloadUtil" const val BASENAME = "%(title).200B" const val EXTENSION = ".%(ext)s" private const val ID = "[%(id)s]" private const val CLIP_TIMESTAMP = "%(section_start)d-%(section_end)d" const val OUTPUT_TEMPLATE_DEFAULT = BASENAME + EXTENSION const val OUTPUT_TEMPLATE_ID = "$BASENAME $ID$EXTENSION" private const val OUTPUT_TEMPLATE_CLIPS = "$BASENAME [$CLIP_TIMESTAMP]$EXTENSION" private const val OUTPUT_TEMPLATE_CHAPTERS = "chapter:$BASENAME/%(section_number)d - %(section_title).200B$EXTENSION" private const val OUTPUT_TEMPLATE_SPLIT = "$BASENAME/$OUTPUT_TEMPLATE_DEFAULT" private const val PLAYLIST_TITLE_SUBDIRECTORY_PREFIX = "%(playlist)s/" private const val CROP_ARTWORK_COMMAND = """--ppa "ffmpeg: -c:v mjpeg -vf crop=\"'if(gt(ih,iw),iw,ih)':'if(gt(iw,ih),ih,iw)'\""""" @CheckResult fun getPlaylistOrVideoInfo( playlistURL: String, downloadPreferences: DownloadPreferences = DownloadPreferences.createFromPreferences(), ): Result = YoutubeDL.runCatching { ToastUtil.makeToastSuspend(context.getString(R.string.fetching_playlist_info)) val request = YoutubeDLRequest(playlistURL) with(request) { // addOption("--compat-options", "no-youtube-unavailable-videos") addOption("--flat-playlist") addOption("--dump-single-json") addOption("-o", BASENAME) addOption("-R", "1") addOption("--socket-timeout", "5") downloadPreferences.run { if (extractAudio) { addOption("-x") } applyFormatSorter(this, toFormatSorter()) if (proxy) { enableProxy(proxyUrl) } if (forceIpv4) { addOption("-4") } if (cookies) { enableCookies(userAgentString) } if (restrictFilenames) { addOption("--restrict-filenames") } } } execute(request, playlistURL).out.run { val playlistInfo = jsonFormat.decodeFromString(this) if (playlistInfo.type != "playlist") { jsonFormat.decodeFromString(this) } else playlistInfo } } @CheckResult private fun getVideoInfo( request: YoutubeDLRequest, taskKey: String? = null, ): Result = request.runCatching { val response: YoutubeDLResponse = YoutubeDL.getInstance().execute(request, taskKey, null) jsonFormat.decodeFromString(response.out) } @CheckResult fun fetchVideoInfoFromUrl( url: String, playlistIndex: Int? = null, taskKey: String? = null, preferences: DownloadPreferences = DownloadPreferences.createFromPreferences(), ): Result { with(preferences) { val request = YoutubeDLRequest(url).apply { addOption("-o", BASENAME) if (restrictFilenames) { addOption("--restrict-filenames") } if (extractAudio) { addOption("-x") } applyFormatSorter(this@with, toFormatSorter()) if (cookies) { enableCookies(userAgentString) } if (proxy) { enableProxy(proxyUrl) } if (forceIpv4) { addOption("-4") } /* if (debug) { addOption("-v") }*/ if (autoSubtitle) { addOption("--write-auto-subs") if (!autoTranslatedSubtitles) { addOption("--extractor-args", "youtube:skip=translated_subs") } } if (playlistIndex != null) { addOption("--playlist-items", playlistIndex) addOption("--dump-json") } else { addOption("--dump-single-json") } addOption("-R", "1") addOption("--no-playlist") addOption("--socket-timeout", "5") } return getVideoInfo(request, taskKey) } } @Serializable data class DownloadPreferences( val extractAudio: Boolean, val createThumbnail: Boolean, val downloadPlaylist: Boolean, val subdirectoryExtractor: Boolean, val subdirectoryPlaylistTitle: Boolean, val commandDirectory: String, val downloadSubtitle: Boolean, val embedSubtitle: Boolean, val keepSubtitle: Boolean, val subtitleLanguage: String, val autoSubtitle: Boolean, val autoTranslatedSubtitles: Boolean, val convertSubtitle: Int, val concurrentFragments: Int, val sponsorBlock: Boolean, val sponsorBlockCategory: String, val cookies: Boolean, val aria2c: Boolean, val useCustomAudioPreset: Boolean, val audioFormat: Int, val audioQuality: Int, val convertAudio: Boolean, val formatSorting: Boolean, val sortingFields: String, val audioConvertFormat: Int, val videoFormat: Int, val formatIdString: String, val videoResolution: Int, val privateMode: Boolean, val rateLimit: Boolean, val maxDownloadRate: String, val privateDirectory: Boolean, val cropArtwork: Boolean, val sdcard: Boolean, val sdcardUri: String, val embedThumbnail: Boolean, val videoClips: List, val splitByChapter: Boolean, val debug: Boolean, val proxy: Boolean, val proxyUrl: String, val newTitle: String, val userAgentString: String, val outputTemplate: String, val useDownloadArchive: Boolean, val embedMetadata: Boolean, val restrictFilenames: Boolean, val supportAv1HardwareDecoding: Boolean, val forceIpv4: Boolean, val mergeAudioStream: Boolean, val mergeToMkv: Boolean, ) { companion object { val EMPTY = DownloadPreferences( extractAudio = false, createThumbnail = false, downloadPlaylist = false, subdirectoryExtractor = false, subdirectoryPlaylistTitle = false, commandDirectory = "", downloadSubtitle = false, embedSubtitle = false, keepSubtitle = false, subtitleLanguage = "", autoSubtitle = false, autoTranslatedSubtitles = false, convertSubtitle = 0, concurrentFragments = 0, sponsorBlock = false, sponsorBlockCategory = "", cookies = false, aria2c = false, audioFormat = 0, audioQuality = 0, convertAudio = false, formatSorting = false, sortingFields = "", audioConvertFormat = 0, videoFormat = 0, formatIdString = "", videoResolution = 0, privateMode = false, rateLimit = false, maxDownloadRate = "", privateDirectory = false, cropArtwork = false, sdcard = false, sdcardUri = "", embedThumbnail = false, videoClips = emptyList(), splitByChapter = false, debug = false, proxy = false, proxyUrl = "", newTitle = "", userAgentString = "", outputTemplate = "", useDownloadArchive = false, embedMetadata = false, restrictFilenames = false, supportAv1HardwareDecoding = false, forceIpv4 = false, mergeAudioStream = false, mergeToMkv = false, useCustomAudioPreset = false, ) fun createFromPreferences(): DownloadPreferences { val downloadSubtitle = SUBTITLE.getBoolean() val embedSubtitle = EMBED_SUBTITLE.getBoolean() return DownloadPreferences( extractAudio = EXTRACT_AUDIO.getBoolean(), createThumbnail = THUMBNAIL.getBoolean(), downloadPlaylist = PLAYLIST.getBoolean(), subdirectoryExtractor = SUBDIRECTORY_EXTRACTOR.getBoolean(), subdirectoryPlaylistTitle = SUBDIRECTORY_PLAYLIST_TITLE.getBoolean(), commandDirectory = COMMAND_DIRECTORY.getString(), downloadSubtitle = downloadSubtitle, embedSubtitle = embedSubtitle, keepSubtitle = KEEP_SUBTITLE_FILES.getBoolean(), subtitleLanguage = SUBTITLE_LANGUAGE.getString(), autoSubtitle = AUTO_SUBTITLE.getBoolean(), autoTranslatedSubtitles = AUTO_TRANSLATED_SUBTITLES.getBoolean(), convertSubtitle = CONVERT_SUBTITLE.getInt(), concurrentFragments = CONCURRENT.getInt(), sponsorBlock = SPONSORBLOCK.getBoolean(), sponsorBlockCategory = PreferenceUtil.getSponsorBlockCategories(), cookies = COOKIES.getBoolean(), aria2c = ARIA2C.getBoolean(), useCustomAudioPreset = USE_CUSTOM_AUDIO_PRESET.getBoolean(), audioFormat = AUDIO_FORMAT.getInt(), audioQuality = AUDIO_QUALITY.getInt(), convertAudio = AUDIO_CONVERT.getBoolean(), formatSorting = FORMAT_SORTING.getBoolean(), sortingFields = SORTING_FIELDS.getString(), audioConvertFormat = PreferenceUtil.getAudioConvertFormat(), videoFormat = PreferenceUtil.getVideoFormat(), formatIdString = "", videoResolution = PreferenceUtil.getVideoResolution(), privateMode = PRIVATE_MODE.getBoolean(), rateLimit = RATE_LIMIT.getBoolean(), maxDownloadRate = PreferenceUtil.getMaxDownloadRate(), privateDirectory = PRIVATE_DIRECTORY.getBoolean(), cropArtwork = CROP_ARTWORK.getBoolean(), sdcard = SDCARD_DOWNLOAD.getBoolean(), sdcardUri = SDCARD_URI.getString(), embedThumbnail = EMBED_THUMBNAIL.getBoolean(), videoClips = emptyList(), splitByChapter = false, debug = DEBUG.getBoolean(), proxy = PROXY.getBoolean(), proxyUrl = PROXY_URL.getString(), newTitle = "", userAgentString = USER_AGENT_STRING.run { if (USER_AGENT.getBoolean()) getString() else "" }, outputTemplate = OUTPUT_TEMPLATE.getString(), useDownloadArchive = DOWNLOAD_ARCHIVE.getBoolean(), embedMetadata = EMBED_METADATA.getBoolean(), restrictFilenames = RESTRICT_FILENAMES.getBoolean(), supportAv1HardwareDecoding = checkIfAv1HardwareAccelerated(), forceIpv4 = FORCE_IPV4.getBoolean(), mergeAudioStream = false, mergeToMkv = (downloadSubtitle && embedSubtitle) || MERGE_OUTPUT_MKV.getBoolean(), ) } } } private fun YoutubeDLRequest.enableCookies(userAgentString: String): YoutubeDLRequest = this.addOption("--cookies", context.getCookiesFile().absolutePath).apply { if (userAgentString.isNotEmpty()) { addOption("--add-header", "User-Agent:$userAgentString") } } private fun YoutubeDLRequest.enableProxy(proxyUrl: String): YoutubeDLRequest = this.addOption("--proxy", proxyUrl) private fun YoutubeDLRequest.useDownloadArchive(): YoutubeDLRequest = this.addOption("--download-archive", context.getArchiveFile().absolutePath) @CheckResult fun getCookieListFromDatabase(): Result> = runCatching { CookieManager.getInstance().run { if (!hasCookies()) throw Exception("There is no cookies in the database!") flush() } SQLiteDatabase.openDatabase( context.dataDir.resolve("app_webview/Default/Cookies").absolutePath, null, OPEN_READONLY, ) .run { val projection = arrayOf( CookieScheme.HOST, CookieScheme.EXPIRY, CookieScheme.PATH, CookieScheme.NAME, CookieScheme.VALUE, CookieScheme.SECURE, ) val cookieList = mutableListOf() query("cookies", projection, null, null, null, null, null).run { while (moveToNext()) { val expiry = getLong(getColumnIndexOrThrow(CookieScheme.EXPIRY)) val name = getString(getColumnIndexOrThrow(CookieScheme.NAME)) val value = getString(getColumnIndexOrThrow(CookieScheme.VALUE)) val path = getString(getColumnIndexOrThrow(CookieScheme.PATH)) val secure = getLong(getColumnIndexOrThrow(CookieScheme.SECURE)) == 1L val hostKey = getString(getColumnIndexOrThrow(CookieScheme.HOST)) val host = if (hostKey[0] != '.') ".$hostKey" else hostKey cookieList.add( Cookie( domain = host, name = name, value = value, path = path, secure = secure, expiry = expiry, ) ) } close() } close() cookieList } } fun List.toCookiesFileContent(): String = this.fold(StringBuilder(COOKIE_HEADER)) { acc, cookie -> acc.append(cookie.toNetscapeCookieString()).append("\n") } .toString() fun getCookiesContentFromDatabase(): Result = getCookieListFromDatabase().mapCatching { it.toCookiesFileContent() } private fun YoutubeDLRequest.enableAria2c(): YoutubeDLRequest = this.addOption("--downloader", "libaria2c.so") private fun YoutubeDLRequest.addOptionsForVideoDownloads( downloadPreferences: DownloadPreferences ): YoutubeDLRequest = this.apply { downloadPreferences.run { addOption("--add-metadata") addOption("--no-embed-info-json") if (formatIdString.isNotEmpty()) { addOption("-f", formatIdString) if (mergeAudioStream) { addOption("--audio-multistreams") } } else { applyFormatSorter(this, toFormatSorter()) } if (downloadSubtitle) { if (autoSubtitle) { addOption("--write-auto-subs") if (!autoTranslatedSubtitles) { addOption("--extractor-args", "youtube:skip=translated_subs") } } subtitleLanguage .takeIf { it.isNotEmpty() } ?.let { addOption("--sub-langs", it) } if (embedSubtitle) { addOption("--embed-subs") if (keepSubtitle) { addOption("--write-subs") } } else { addOption("--write-subs") } when (convertSubtitle) { CONVERT_ASS -> addOption("--convert-subs", "ass") CONVERT_SRT -> addOption("--convert-subs", "srt") CONVERT_VTT -> addOption("--convert-subs", "vtt") CONVERT_LRC -> addOption("--convert-subs", "lrc") else -> {} } } if (mergeToMkv) { addOption("--remux-video", "mkv") addOption("--merge-output-format", "mkv") } if (embedThumbnail) { addOption("--embed-thumbnail") } if (videoClips.isEmpty()) addOption("--embed-chapters") } } @CheckResult private fun DownloadPreferences.toAudioFormatSorter(): String = this.run { if (!useCustomAudioPreset) return@run "" val format = when (audioFormat) { M4A -> "acodec:aac" OPUS -> "acodec:opus" else -> "" } val quality = when (audioQuality) { HIGH -> "abr~192" MEDIUM -> "abr~128" LOW -> "abr~64" else -> "" } return@run connectWithDelimiter(format, quality, delimiter = ",") } @CheckResult private fun DownloadPreferences.toVideoFormatSorter(): String = this.run { val format = when (videoFormat) { FORMAT_COMPATIBILITY -> "proto,vcodec:h264,ext" FORMAT_QUALITY -> if (supportAv1HardwareDecoding) { "vcodec:av01" } else { "vcodec:vp9.2" } else -> "" } val res = when (videoResolution) { 1 -> "res:2160" 2 -> "res:1440" 3 -> "res:1080" 4 -> "res:720" 5 -> "res:480" 6 -> "res:360" 7 -> "+res" else -> "" } val sorter = if (videoFormat == FORMAT_COMPATIBILITY) { connectWithDelimiter(format, res, delimiter = ",") } else { connectWithDelimiter(res, format, delimiter = ",") } return@run sorter } private fun YoutubeDLRequest.applyFormatSorter( preferences: DownloadPreferences, sorter: String, ) = preferences.run { if (formatSorting && sortingFields.isNotEmpty()) addOption("-S", sortingFields) else if (sorter.isNotEmpty()) addOption("-S", sorter) else {} } @CheckResult fun DownloadPreferences.toFormatSorter(): String = connectWithDelimiter( this.toVideoFormatSorter(), this.toAudioFormatSorter(), delimiter = ",", ) private fun YoutubeDLRequest.addOptionsForAudioDownloads( id: String, preferences: DownloadPreferences, playlistUrl: String, ): YoutubeDLRequest = this.apply { with(preferences) { addOption("-x") if (downloadSubtitle) { addOption("--write-subs") if (autoSubtitle) { addOption("--write-auto-subs") if (!autoTranslatedSubtitles) { addOption("--extractor-args", "youtube:skip=translated_subs") } } subtitleLanguage .takeIf { it.isNotEmpty() } ?.let { addOption("--sub-langs", it) } when (convertSubtitle) { CONVERT_ASS -> addOption("--convert-subs", "ass") CONVERT_SRT -> addOption("--convert-subs", "srt") CONVERT_VTT -> addOption("--convert-subs", "vtt") CONVERT_LRC -> addOption("--convert-subs", "lrc") else -> {} } } if (formatIdString.isNotEmpty()) { addOption("-f", formatIdString) if (mergeAudioStream) { addOption("--audio-multistreams") } } else if (convertAudio) { when (audioConvertFormat) { CONVERT_MP3 -> { addOption("--audio-format", "mp3") } CONVERT_M4A -> { addOption("--audio-format", "m4a") } } } else { applyFormatSorter(preferences, toAudioFormatSorter()) } if (embedMetadata) { addOption("--embed-metadata") addOption("--embed-thumbnail") addOption("--convert-thumbnails", "jpg") if (cropArtwork) { val configFile = context.getConfigFile(id) FileUtil.writeContentToFile(CROP_ARTWORK_COMMAND, configFile) addOption("--config", configFile.absolutePath) } } addOption("--parse-metadata", "%(release_year,upload_date)s:%(meta_date)s") if (playlistUrl.isNotEmpty()) { addOption("--parse-metadata", "%(album,playlist,title)s:%(meta_album)s") addOption("--parse-metadata", "%(track_number,playlist_index)d:%(meta_track)s") } else { addOption("--parse-metadata", "%(album,title)s:%(meta_album)s") } } } private fun insertInfoIntoDownloadHistory( videoInfo: VideoInfo, filePaths: List, ): List = filePaths.onEach { DatabaseUtil.insertInfo(videoInfo.toDownloadedVideoInfo(videoPath = it)) } private fun VideoInfo.toDownloadedVideoInfo( id: Int = 0, videoPath: String, ): DownloadedVideoInfo = this.run { DownloadedVideoInfo( id = id, videoTitle = title, videoAuthor = uploader ?: channel ?: uploaderId.toString(), videoUrl = webpageUrl ?: originalUrl.toString(), thumbnailUrl = thumbnail.toHttpsUrl(), videoPath = videoPath, extractor = extractorKey, ) } private fun insertSplitChapterIntoHistory(videoInfo: VideoInfo, filePaths: List) = filePaths.onEach { DatabaseUtil.insertInfo( videoInfo.toDownloadedVideoInfo(videoPath = it).copy(videoTitle = it.getFileName()) ) } @CheckResult fun downloadVideo( videoInfo: VideoInfo? = null, playlistUrl: String = "", playlistItem: Int = 0, taskId: String, downloadPreferences: DownloadPreferences, progressCallback: ((Float, Long, String) -> Unit)?, ): Result> { if (videoInfo == null) return Result.failure(Throwable(context.getString(R.string.fetch_info_error_msg))) with(downloadPreferences) { val url = playlistUrl.ifEmpty { videoInfo.originalUrl ?: videoInfo.webpageUrl ?: return Result.failure( Throwable(context.getString(R.string.fetch_info_error_msg)) ) } val request = YoutubeDLRequest(url) val pathBuilder = StringBuilder() val outputBuilder = StringBuilder() request .apply { addOption("--no-mtime") // addOption("-v") if (cookies) { enableCookies(userAgentString) } if (restrictFilenames) { addOption("--restrict-filenames") } if (proxy) { enableProxy(proxyUrl) } if (forceIpv4) { addOption("-4") } if (debug) { addOption("-v") } if (useDownloadArchive) { val archiveFile = context.getArchiveFile() val archiveFileContent = archiveFile.readText() if (archiveFileContent.contains("${videoInfo.extractor} ${videoInfo.id}")) { return Result.failure( YoutubeDLException( context.getString(R.string.download_archive_error) ) ) } else { useDownloadArchive() } } if (rateLimit && maxDownloadRate.isNumberInRange(1, 1000000)) { addOption("-r", "${maxDownloadRate}K") } if (playlistItem != 0 && downloadPlaylist) { addOption("--playlist-items", playlistItem) if (subdirectoryPlaylistTitle && !videoInfo.playlist.isNullOrEmpty()) { outputBuilder.append(PLAYLIST_TITLE_SUBDIRECTORY_PREFIX) } // addOption("--compat-options", // "no-youtube-unavailable-videos") } else { addOption("--no-playlist") } if (aria2c) { enableAria2c() } else if (concurrentFragments > 1) { addOption("--concurrent-fragments", concurrentFragments) } if (extractAudio || (videoInfo.vcodec == "none")) { if (privateDirectory) pathBuilder.append(App.privateDownloadDir) else pathBuilder.append(audioDownloadDir) addOptionsForAudioDownloads( id = videoInfo.id, preferences = downloadPreferences, playlistUrl = playlistUrl, ) } else { if (privateDirectory) pathBuilder.append(App.privateDownloadDir) else pathBuilder.append(videoDownloadDir) addOptionsForVideoDownloads(downloadPreferences) } if (sponsorBlock) { addOption("--sponsorblock-remove", sponsorBlockCategory) } if (createThumbnail) { addOption("--write-thumbnail") addOption("--convert-thumbnails", "png") } if (subdirectoryExtractor) { pathBuilder.append("/${videoInfo.extractorKey}") } if (sdcard) { addOption("-P", context.getSdcardTempDir(videoInfo.id).absolutePath) } else { addOption("-P", pathBuilder.toString()) } videoClips.forEach { addOption( "--download-sections", "*%d-%d".format(locale = Locale.US, it.start, it.end), ) } if (newTitle.isNotEmpty()) { addCommands(listOf("--replace-in-metadata", "title", ".+", newTitle)) } if (Build.VERSION.SDK_INT > 23 && !sdcard) addOption("-P", "temp:" + getExternalTempDir()) if (splitByChapter) { addOption("-o", OUTPUT_TEMPLATE_CHAPTERS) addOption("--split-chapters") } val output = if (splitByChapter) { OUTPUT_TEMPLATE_SPLIT } else if (videoClips.isEmpty()) { outputTemplate } else { OUTPUT_TEMPLATE_CLIPS } addOption("-o", outputBuilder.append(output).toString()) for (s in request.buildCommand()) Log.d(TAG, s) } .runCatching { YoutubeDL.getInstance() .execute(request = this, processId = taskId, callback = progressCallback) } .onFailure { th -> return if ( sponsorBlock && th.message?.contains("Unable to communicate with SponsorBlock API") == true ) { th.printStackTrace() onFinishDownloading( preferences = this, videoInfo = videoInfo, downloadPath = pathBuilder.toString(), sdcardUri = sdcardUri, ) } else Result.failure(th) } return onFinishDownloading( preferences = this, videoInfo = videoInfo, downloadPath = pathBuilder.toString(), sdcardUri = sdcardUri, ) } } private fun onFinishDownloading( preferences: DownloadPreferences, videoInfo: VideoInfo, downloadPath: String, sdcardUri: String, ): Result> = preferences.run { val fileName = preferences.newTitle.ifEmpty { videoInfo.filename ?: videoInfo.requestedDownloads?.firstOrNull()?.filename ?: videoInfo.title } Log.d(TAG, "onFinishDownloading: $fileName") if (sdcard) { moveFilesToSdcard( sdcardUri = sdcardUri, tempPath = context.getSdcardTempDir(videoInfo.id), ) .onSuccess { if (privateMode) { return Result.success(emptyList()) } else if (splitByChapter) { insertSplitChapterIntoHistory(videoInfo, it) } else { insertInfoIntoDownloadHistory(videoInfo, it) } } } else { FileUtil.scanFileToMediaLibraryPostDownload( title = fileName, downloadDir = downloadPath, ) .run { if (privateMode) Result.success(emptyList()) else Result.success( if (splitByChapter) { insertSplitChapterIntoHistory(videoInfo, this) } else { insertInfoIntoDownloadHistory(videoInfo, this) } ) } } } @CheckResult fun executeCustomCommandTask( urlString: String, taskId: String, template: CommandTemplate, preferences: DownloadPreferences, progressCallback: ((Float, Long, String) -> Unit), ): Result { val urlList = urlString.split(Regex("[\n ]")).filter { it.isNotBlank() } val request = with(preferences) { YoutubeDLRequest(urlList).apply { commandDirectory.takeIf { it.isNotEmpty() }?.let { addOption("-P", it) } addOption("--newline") if (aria2c) { enableAria2c() } if (useDownloadArchive) { useDownloadArchive() } if (restrictFilenames) { addOption("--restrict-filenames") } addOption( "--config-locations", FileUtil.writeContentToFile(template.template, context.getConfigFile()) .absolutePath, ) if (cookies) { enableCookies(userAgentString) } } } return runCatching { YoutubeDL.getInstance() .execute(request = request, processId = taskId, callback = progressCallback) } } suspend fun executeCommandInBackground( url: String, template: CommandTemplate = PreferenceUtil.getTemplate(), downloadPreferences: DownloadPreferences = DownloadPreferences.createFromPreferences(), ) { downloadPreferences.run { val taskId = Downloader.makeKey(url = url, templateName = template.name) val notificationId = taskId.toNotificationId() val urlList = url.split(Regex("[\n ]")).filter { it.isNotBlank() } ToastUtil.makeToastSuspend(context.getString(R.string.start_execute)) val request = YoutubeDLRequest(urlList).apply { commandDirectory.takeIf { it.isNotEmpty() }?.let { addOption("-P", it) } addOption("--newline") if (aria2c) { enableAria2c() } if (useDownloadArchive) { useDownloadArchive() } if (restrictFilenames) { addOption("--restrict-filenames") } addOption( "--config-locations", FileUtil.writeContentToFile(template.template, context.getConfigFile()) .absolutePath, ) if (cookies) { enableCookies(userAgentString) } } onProcessStarted() withContext(Dispatchers.Main) { onTaskStarted(template, url) } runCatching { val response = YoutubeDL.getInstance().execute(request = request, processId = taskId) { progress, _, text -> NotificationUtil.makeNotificationForCustomCommand( notificationId = notificationId, taskId = taskId, progress = progress.toInt(), templateName = template.name, taskUrl = url, text = text, ) Downloader.updateTaskOutput( template = template, url = url, line = text, progress = progress, ) } onTaskEnded(template, url, response.out + "\n" + response.err) } .onFailure { it.printStackTrace() if (it is YoutubeDL.CanceledException) return@onFailure it.message.run { if (isNullOrEmpty()) onTaskEnded(template, url) else onTaskError(this, template, url) } } onProcessEnded() } } private fun checkIfAv1HardwareAccelerated(): Boolean { if (PreferenceUtil.containsKey(AV1_HARDWARE_ACCELERATED)) { return AV1_HARDWARE_ACCELERATED.getBoolean() } else { val res = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { false } else { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.any { info -> info.supportedTypes.any { it.equals("video/av01", ignoreCase = true) } && info.isHardwareAccelerated } } AV1_HARDWARE_ACCELERATED.updateBoolean(res) return res } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/util/FileUtil.kt ================================================ package com.junkfood.seal.util import android.content.ClipData import android.content.Context import android.content.Intent import android.media.MediaScannerConnection import android.net.Uri import android.os.Environment import android.provider.DocumentsContract import android.util.Log import android.webkit.MimeTypeMap import androidx.annotation.CheckResult import androidx.core.content.FileProvider import androidx.documentfile.provider.DocumentFile import com.junkfood.seal.App.Companion.context import com.junkfood.seal.R import java.io.File import okhttp3.internal.closeQuietly const val AUDIO_REGEX = "(mp3|aac|opus|m4a)$" const val THUMBNAIL_REGEX = "\\.(jpg|png)$" const val SUBTITLE_REGEX = "\\.(lrc|vtt|srt|ass|json3|srv.|ttml)$" private const val PRIVATE_DIRECTORY_SUFFIX = ".Seal" object FileUtil { fun openFileFromResult(downloadResult: Result>) { val filePaths = downloadResult.getOrNull() if (filePaths.isNullOrEmpty()) return openFile(filePaths.first()) { ToastUtil.makeToastSuspend(context.getString(R.string.file_unavailable)) } } inline fun openFile(path: String, onFailureCallback: (Throwable) -> Unit) = path .runCatching { createIntentForOpeningFile(this)?.run { context.startActivity(this) } ?: throw Exception() } .onFailure { onFailureCallback(it) } private fun createIntentForFile(path: String?): Intent? { if (path == null) return null val uri = path .runCatching { DocumentFile.fromSingleUri(context, Uri.parse(path)).run { if (this?.exists() == true) { this.uri } else if (File(this@runCatching).exists()) { FileProvider.getUriForFile( context, context.getFileProvider(), File(this@runCatching), ) } else null } } .getOrNull() ?: return null return Intent().apply { flags = Intent.FLAG_GRANT_READ_URI_PERMISSION data = uri } } fun createIntentForOpeningFile(path: String?): Intent? = createIntentForFile(path)?.let { it.apply { action = (Intent.ACTION_VIEW) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } } fun createIntentForSharingFile(path: String?): Intent? = createIntentForFile(path)?.apply { action = Intent.ACTION_SEND putExtra(Intent.EXTRA_STREAM, data) val mimeType = data?.let { context.contentResolver.getType(it) } ?: "media/*" setDataAndType(this.data, mimeType) clipData = ClipData(null, arrayOf(mimeType), ClipData.Item(data)) } fun Context.getFileProvider() = "$packageName.provider" fun String.getFileSize(): Long = this.run { val length = File(this).length() if (length == 0L) DocumentFile.fromSingleUri(context, Uri.parse(this))?.length() ?: 0L else length } fun String.getFileName(): String = this.run { File(this).nameWithoutExtension.ifEmpty { DocumentFile.fromSingleUri(context, Uri.parse(this))?.name ?: "video" } } fun deleteFile(path: String) = path.runCatching { if (!File(path).delete()) DocumentFile.fromSingleUri(context, Uri.parse(this))?.delete() } @CheckResult fun scanFileToMediaLibraryPostDownload(title: String, downloadDir: String): List = File(downloadDir) .walkTopDown() .filter { it.isFile && it.absolutePath.contains(title) } .map { it.absolutePath } .toMutableList() .apply { MediaScannerConnection.scanFile(context, this.toList().toTypedArray(), null, null) removeAll { it.contains(Regex(THUMBNAIL_REGEX)) || it.contains(Regex(SUBTITLE_REGEX)) } } fun scanDownloadDirectoryToMediaLibrary(downloadDir: String) = File(downloadDir) .walkTopDown() .filter { it.isFile } .map { it.absolutePath } .run { MediaScannerConnection.scanFile(context, this.toList().toTypedArray(), null, null) } @CheckResult fun moveFilesToSdcard(tempPath: File, sdcardUri: String): Result> { val uriList = mutableListOf() val destDir = Uri.parse(sdcardUri).run { DocumentsContract.buildDocumentUriUsingTree( this, DocumentsContract.getTreeDocumentId(this), ) } val res = tempPath.runCatching { walkTopDown().forEach { if (it.isDirectory) return@forEach val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(it.extension) ?: "*/*" val destUri = DocumentsContract.createDocument( context.contentResolver, destDir, mimeType, it.name, ) ?: return@forEach val inputStream = it.inputStream() val outputStream = context.contentResolver.openOutputStream(destUri) ?: return@forEach inputStream.copyTo(outputStream) inputStream.closeQuietly() outputStream.closeQuietly() uriList.add(destUri.toString()) } uriList } tempPath.deleteRecursively() return res } fun clearTempFiles(downloadDir: File): Int { var count = 0 downloadDir.walkTopDown().forEach { if (it.isFile && !it.isHidden) { if (it.delete()) count++ } } return count } fun Context.getConfigDirectory(): File = cacheDir fun Context.getConfigFile(suffix: String = "") = File(getConfigDirectory(), "config$suffix.txt") fun Context.getCookiesFile() = File(getConfigDirectory(), "cookies.txt") fun getExternalTempDir() = File(getExternalDownloadDirectory(), "tmp").apply { mkdirs() createEmptyFile(".nomedia") } fun Context.getSdcardTempDir(child: String?): File = getExternalTempDir().run { child?.let { resolve(it) } ?: this } fun Context.getArchiveFile(): File = filesDir.createEmptyFile("archive.txt").getOrThrow() fun Context.getInternalTempDir() = File(filesDir, "tmp") internal fun getExternalDownloadDirectory() = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "Seal") .also { it.mkdir() } internal fun getExternalPrivateDownloadDirectory() = File( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), PRIVATE_DIRECTORY_SUFFIX, ) fun File.createEmptyFile(fileName: String): Result = this.runCatching { mkdirs() resolve(fileName).apply { this@apply.createNewFile() } } .onFailure { it.printStackTrace() } fun writeContentToFile(content: String, file: File): File = file.apply { writeText(content) } fun getRealPath(treeUri: Uri): String { val path: String = treeUri.path.toString() Log.d(TAG, path) if (!path.contains("primary:")) { ToastUtil.makeToast("This directory is not supported") return getExternalDownloadDirectory().absolutePath } val last: String = path.split("primary:").last() return Environment.getExternalStorageDirectory().absolutePath + "/$last" } private const val TAG = "FileUtil" } ================================================ FILE: app/src/main/java/com/junkfood/seal/util/LanguageSettings.kt ================================================ package com.junkfood.seal.util import androidx.appcompat.app.AppCompatDelegate import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.core.os.LocaleListCompat import com.junkfood.seal.R import java.util.Locale // Do not modify private const val SIMPLIFIED_CHINESE = 1 private const val ENGLISH = 2 private const val CZECH = 3 private const val FRENCH = 4 private const val GERMAN = 5 private const val NORWEGIAN_BOKMAL = 6 private const val DANISH = 7 private const val SPANISH = 8 private const val TURKISH = 9 private const val UKRAINIAN = 10 private const val RUSSIAN = 11 private const val ARABIC = 12 private const val PERSIAN = 13 private const val INDONESIAN = 14 private const val FILIPINO = 15 private const val ITALIAN = 16 private const val DUTCH = 17 private const val PORTUGUESE_BRAZIL = 18 private const val JAPANESE = 19 private const val POLISH = 20 private const val HUNGARIAN = 21 private const val MALAY = 22 private const val TRADITIONAL_CHINESE = 23 private const val VIETNAMESE = 24 private const val BELARUSIAN = 25 private const val CROATIAN = 26 private const val BASQUE = 27 private const val HINDI = 28 private const val MALAYALAM = 29 private const val SINHALA = 30 private const val SERBIAN = 31 private const val AZERBAIJANI = 32 private const val NORWEGIAN_NYNORSK = 33 private const val PUNJABI = 34 private const val TAMIL = 35 private const val KOREAN = 36 private const val SWEDISH = 37 private const val PORTUGUESE_PORTUGAL = 38 private const val CATALAN = 39 private const val HEBREW = 40 private const val PORTUGUESE = 41 private const val THAI = 42 private const val BENGALI = 43 private const val KHMER = 44 private const val KANNADA = 45 private const val GREEK = 46 private const val MONGOLIAN = 47 val LocaleLanguageCodeMap = mapOf( Locale("ar") to ARABIC, Locale("az") to AZERBAIJANI, Locale("eu") to BASQUE, Locale("be") to BELARUSIAN, Locale("bn") to BENGALI, Locale("ca") to CATALAN, Locale.forLanguageTag("zh-Hans") to SIMPLIFIED_CHINESE, Locale.forLanguageTag("zh-Hant") to TRADITIONAL_CHINESE, Locale("hr") to CROATIAN, Locale("cs") to CZECH, Locale("da") to DANISH, Locale("nl") to DUTCH, Locale("en", "US") to ENGLISH, Locale("fil") to FILIPINO, Locale("fr") to FRENCH, Locale("de") to GERMAN, Locale("el") to GREEK, Locale("he") to HEBREW, Locale("hi") to HINDI, Locale("hu") to HUNGARIAN, Locale("in") to INDONESIAN, Locale("it") to ITALIAN, Locale("ja") to JAPANESE, Locale("kn") to KANNADA, Locale("km") to KHMER, Locale("ko") to KOREAN, Locale("ms") to MALAY, Locale("ml") to MALAYALAM, Locale("mn") to MONGOLIAN, Locale("nb") to NORWEGIAN_BOKMAL, Locale("nn") to NORWEGIAN_NYNORSK, Locale("fa") to PERSIAN, Locale("pl") to POLISH, Locale("pt") to PORTUGUESE, Locale("pt", "PT") to PORTUGUESE_PORTUGAL, Locale("pt", "BR") to PORTUGUESE_BRAZIL, Locale("pa") to PUNJABI, Locale("ru") to RUSSIAN, Locale("sr") to SERBIAN, Locale("si") to SINHALA, Locale("es") to SPANISH, Locale("sv") to SWEDISH, Locale("ta") to TAMIL, Locale("th") to THAI, Locale("tr") to TURKISH, Locale("uk") to UKRAINIAN, Locale("vi") to VIETNAMESE, ) @Composable fun Locale?.toDisplayName(): String = this?.getDisplayName(this) ?: stringResource(id = R.string.follow_system) fun setLanguage(locale: Locale?) { val localeList = locale?.let { LocaleListCompat.create(it) } ?: LocaleListCompat.getEmptyLocaleList() AppCompatDelegate.setApplicationLocales(localeList) } ================================================ FILE: app/src/main/java/com/junkfood/seal/util/NotificationUtil.kt ================================================ package com.junkfood.seal.util import android.annotation.SuppressLint import android.app.Notification import android.app.NotificationChannel import android.app.NotificationChannelGroup import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Build import android.util.Log import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE import com.junkfood.seal.App.Companion.context import com.junkfood.seal.NotificationActionReceiver import com.junkfood.seal.NotificationActionReceiver.Companion.ACTION_CANCEL_TASK import com.junkfood.seal.NotificationActionReceiver.Companion.ACTION_ERROR_REPORT import com.junkfood.seal.NotificationActionReceiver.Companion.ACTION_KEY import com.junkfood.seal.NotificationActionReceiver.Companion.ERROR_REPORT_KEY import com.junkfood.seal.NotificationActionReceiver.Companion.NOTIFICATION_ID_KEY import com.junkfood.seal.NotificationActionReceiver.Companion.TASK_ID_KEY import com.junkfood.seal.R import com.junkfood.seal.util.PreferenceUtil.getBoolean private const val TAG = "NotificationUtil" @SuppressLint("StaticFieldLeak") object NotificationUtil { private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private const val PROGRESS_MAX = 100 private const val PROGRESS_INITIAL = 0 private const val CHANNEL_ID = "download_notification" private const val SERVICE_CHANNEL_ID = "download_service" private const val NOTIFICATION_GROUP_ID = "seal.download.notification" private const val DEFAULT_NOTIFICATION_ID = 100 const val SERVICE_NOTIFICATION_ID = 123 private lateinit var serviceNotification: Notification // private var builder = // NotificationCompat.Builder(context, CHANNEL_ID).setSmallIcon(R.drawable.ic_stat_seal) private val commandNotificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID).setSmallIcon(R.drawable.ic_stat_seal) @RequiresApi(Build.VERSION_CODES.O) fun createNotificationChannel() { val name = context.getString(R.string.channel_name) val descriptionText = context.getString(R.string.channel_description) val importance = NotificationManager.IMPORTANCE_LOW val channelGroup = NotificationChannelGroup(NOTIFICATION_GROUP_ID, context.getString(R.string.download)) val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { description = descriptionText group = NOTIFICATION_GROUP_ID } val serviceChannel = NotificationChannel(SERVICE_CHANNEL_ID, name, importance).apply { description = context.getString(R.string.service_title) group = NOTIFICATION_GROUP_ID } notificationManager.createNotificationChannelGroup(channelGroup) notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(serviceChannel) } fun notifyProgress( title: String, notificationId: Int = DEFAULT_NOTIFICATION_ID, progress: Int = PROGRESS_INITIAL, taskId: String? = null, text: String? = null, ) { if (!NOTIFICATION.getBoolean()) return val pendingIntent = taskId?.let { Intent(context.applicationContext, NotificationActionReceiver::class.java) .putExtra(TASK_ID_KEY, taskId) .putExtra(NOTIFICATION_ID_KEY, notificationId) .putExtra(ACTION_KEY, ACTION_CANCEL_TASK) .run { PendingIntent.getBroadcast( context.applicationContext, notificationId, this, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE, ) } } NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.ic_stat_seal) .setContentTitle(title) .setProgress(PROGRESS_MAX, progress, progress <= 0) .setOngoing(true) .setOnlyAlertOnce(true) .setStyle(NotificationCompat.BigTextStyle().bigText(text)) .run { pendingIntent?.let { addAction(R.drawable.outline_cancel_24, context.getString(R.string.cancel), it) } notificationManager.notify(notificationId, build()) } } fun finishNotification( notificationId: Int = DEFAULT_NOTIFICATION_ID, title: String? = null, text: String? = null, intent: PendingIntent? = null, ) { Log.d(TAG, "finishNotification: ") notificationManager.cancel(notificationId) if (!NOTIFICATION.getBoolean()) return val builder = NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.ic_stat_seal) .setContentText(text) .setOngoing(false) .setAutoCancel(true) title?.let { builder.setContentTitle(title) } intent?.let { builder.setContentIntent(intent) } notificationManager.notify(notificationId, builder.build()) } fun finishNotificationForCustomCommands( notificationId: Int = DEFAULT_NOTIFICATION_ID, title: String? = null, text: String? = null, ) { // notificationManager.cancel(notificationId) val builder = NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.ic_stat_seal) .setContentText(text) .setProgress(0, 0, false) .setAutoCancel(true) .setOngoing(false) .setStyle(null) title?.let { builder.setContentTitle(title) } notificationManager.notify(notificationId, builder.build()) } fun makeServiceNotification(intent: PendingIntent, text: String? = null): Notification { serviceNotification = NotificationCompat.Builder(context, SERVICE_CHANNEL_ID) .setSmallIcon(R.drawable.ic_stat_seal) .setContentTitle(context.getString(R.string.service_title)) .setContentText(text) .setOngoing(true) .setContentIntent(intent) .setForegroundServiceBehavior(FOREGROUND_SERVICE_IMMEDIATE) .build() return serviceNotification } fun updateServiceNotificationForPlaylist(index: Int, itemCount: Int) { serviceNotification = NotificationCompat.Builder(context, serviceNotification) .setContentTitle(context.getString(R.string.service_title) + " ($index/$itemCount)") .build() notificationManager.notify(SERVICE_NOTIFICATION_ID, serviceNotification) } fun cancelNotification(notificationId: Int) { notificationManager.cancel(notificationId) } fun notifyError( title: String, textId: Int = R.string.download_error_msg, notificationId: Int, report: String, ) { if (!NOTIFICATION.getBoolean()) return val intent = Intent() .setClass(context, NotificationActionReceiver::class.java) .putExtra(NOTIFICATION_ID_KEY, notificationId) .putExtra(ERROR_REPORT_KEY, report) .putExtra(ACTION_KEY, ACTION_ERROR_REPORT) val pendingIntent = PendingIntent.getBroadcast( context, notificationId, intent, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.ic_stat_seal) .setContentTitle(title) .setContentText(context.getString(textId)) .setOngoing(false) .addAction( R.drawable.outline_content_copy_24, context.getString(R.string.copy_error_report), pendingIntent, ) .run { notificationManager.cancel(notificationId) notificationManager.notify(notificationId, build()) } } fun makeNotificationForCustomCommand( notificationId: Int, taskId: String, progress: Int, text: String? = null, templateName: String, taskUrl: String, ) { if (!NOTIFICATION.getBoolean()) return val intent = Intent(context.applicationContext, NotificationActionReceiver::class.java) .putExtra(TASK_ID_KEY, taskId) .putExtra(NOTIFICATION_ID_KEY, notificationId) .putExtra(ACTION_KEY, ACTION_CANCEL_TASK) val pendingIntent = PendingIntent.getBroadcast( context.applicationContext, notificationId, intent, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE, ) NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.ic_stat_seal) .setContentTitle( "[${templateName}_${taskUrl}] " + context.getString(R.string.execute_command_notification) ) .setContentText(text) .setOngoing(true) .setProgress(PROGRESS_MAX, progress, progress == -1) .addAction( R.drawable.outline_cancel_24, context.getString(R.string.cancel), pendingIntent, ) .run { notificationManager.notify(notificationId, build()) } } fun cancelAllNotifications() { notificationManager.cancelAll() } fun areNotificationsEnabled(): Boolean { return if (Build.VERSION.SDK_INT <= 24) true else notificationManager.areNotificationsEnabled() } } ================================================ FILE: app/src/main/java/com/junkfood/seal/util/PreferenceUtil.kt ================================================ package com.junkfood.seal.util import android.os.Build import androidx.annotation.DeprecatedSinceApi import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.google.android.material.color.DynamicColors import com.junkfood.seal.App import com.junkfood.seal.App.Companion.applicationScope import com.junkfood.seal.App.Companion.context import com.junkfood.seal.App.Companion.isDebugBuild import com.junkfood.seal.App.Companion.isFDroidBuild import com.junkfood.seal.R import com.junkfood.seal.database.objects.CommandTemplate import com.junkfood.seal.download.Task import com.junkfood.seal.ui.theme.DEFAULT_SEED_COLOR import com.junkfood.seal.util.PreferenceUtil.getInt import com.kyant.monet.PaletteStyle import com.tencent.mmkv.MMKV import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json const val CUSTOM_COMMAND = "custom_command" const val CONCURRENT = "concurrent_fragments" const val EXTRACT_AUDIO = "extract_audio" const val THUMBNAIL = "create_thumbnail" const val YT_DLP_VERSION = "yt-dlp_init" const val YT_DLP_AUTO_UPDATE = "yt-dlp_update" const val DEBUG = "debug" const val CONFIGURE = "configure" const val DARK_THEME_VALUE = "dark_theme_value" const val AUDIO_CONVERT = "audio_convert" const val AUDIO_CONVERSION_FORMAT = "audio_convert_format" const val AUDIO_FORMAT = "audio_format_preferred" const val AUDIO_QUALITY = "audio_quality" const val VIDEO_FORMAT = "video_format" const val VIDEO_QUALITY = "quality" const val FORMAT_SORTING = "format_sorting" const val SORTING_FIELDS = "sorting_fields" const val WELCOME_DIALOG = "welcome_dialog" const val VIDEO_DIRECTORY = "download_dir" const val AUDIO_DIRECTORY = "audio_dir" const val COMMAND_DIRECTORY = "command_directory" const val SDCARD_DOWNLOAD = "sdcard_download" const val SDCARD_URI = "sd_card_uri" const val SUBDIRECTORY_EXTRACTOR = "sub-directory" const val SUBDIRECTORY_PLAYLIST_TITLE = "subdirectory_playlist_title" const val PLAYLIST = "playlist" private const val LANGUAGE = "language" const val NOTIFICATION = "notification" private const val THEME_COLOR = "theme_color" const val PALETTE_STYLE = "palette_style" const val SUBTITLE = "subtitle" const val EMBED_SUBTITLE = "embed_subtitle" const val KEEP_SUBTITLE_FILES = "keep_subtitle" const val SUBTITLE_LANGUAGE = "sub_lang" const val AUTO_SUBTITLE = "auto_subtitle" const val CONVERT_SUBTITLE = "convert_subtitle" const val AUTO_TRANSLATED_SUBTITLES = "translated_subs" const val TEMPLATE_ID = "template_id" const val MAX_FILE_SIZE = "max_file_size" const val SPONSORBLOCK = "sponsorblock" const val SPONSORBLOCK_CATEGORIES = "sponsorblock_categories" const val ARIA2C = "aria2c" const val COOKIES = "cookies" const val USER_AGENT = "user_agent" const val USER_AGENT_STRING = "user_agent_string" const val AUTO_UPDATE = "auto_update" const val UPDATE_CHANNEL = "update_channel" const val PRIVATE_MODE = "private_mode" private const val DYNAMIC_COLOR = "dynamic_color" const val CELLULAR_DOWNLOAD = "cellular_download" const val RATE_LIMIT = "rate_limit" const val MAX_RATE = "max_rate" private const val HIGH_CONTRAST = "high_contrast" const val DISABLE_PREVIEW = "disable_preview" const val PRIVATE_DIRECTORY = "private_directory" const val CROP_ARTWORK = "crop_artwork" const val EMBED_THUMBNAIL = "embed_thumbnail" const val FORMAT_SELECTION = "format_selection" const val VIDEO_CLIP = "video_clip" const val SHOW_SPONSOR_MSG = "sponsor_msg_v1" const val PROXY = "proxy" const val PROXY_URL = "proxy_url" const val OUTPUT_TEMPLATE = "output_template" const val CUSTOM_OUTPUT_TEMPLATE = "custom_output_template" const val DOWNLOAD_ARCHIVE = "download_archive" const val EMBED_METADATA = "embed_metadata" const val RESTRICT_FILENAMES = "restrict_filenames" const val AV1_HARDWARE_ACCELERATED = "av1_hardware_accelerated" const val FORCE_IPV4 = "force_ipv4" const val MERGE_OUTPUT_MKV = "merge_to_mkv" const val USE_CUSTOM_AUDIO_PRESET = "custom_audio_preset" const val MERGE_MULTI_AUDIO_STREAM = "multi_audio_stream" const val DOWNLOAD_TYPE_INITIALIZATION = "download_type_init" private const val DOWNLOAD_TYPE = "download_type" const val YT_DLP_UPDATE_CHANNEL = "yt-dlp_update_channel" const val YT_DLP_UPDATE_TIME = "yt-dlp_last_update" const val YT_DLP_UPDATE_INTERVAL = "yt-dlp_update_interval" private const val INTERVAL_DAY = 86_400_000L private const val INTERVAL_WEEK = 86_400_000L * 7 private const val INTERVAL_MONTH = 86_400_000L * 30 const val DEFAULT_INTERVAL = INTERVAL_WEEK // every week val UpdateIntervalList = mapOf( INTERVAL_DAY to R.string.every_day, INTERVAL_WEEK to R.string.every_week, INTERVAL_MONTH to R.string.every_month, ) const val NOT_SPECIFIED = 0 const val DEFAULT = NOT_SPECIFIED const val SYSTEM_DEFAULT = NOT_SPECIFIED const val NOT_CONVERT = NOT_SPECIFIED const val NONE = NOT_SPECIFIED const val USE_PREVIOUS_SELECTION = 1 enum class DownloadType { Audio, Video, Playlist, Command, } const val CONVERT_ASS = 1 const val CONVERT_LRC = 2 const val CONVERT_SRT = 3 const val CONVERT_VTT = 4 const val STABLE = 0 const val PRE_RELEASE = 1 const val YT_DLP_STABLE = 0 const val YT_DLP_NIGHTLY = 1 const val OPUS = 1 const val M4A = 2 const val FORMAT_COMPATIBILITY = 1 const val FORMAT_QUALITY = 2 const val CONVERT_MP3 = 0 const val CONVERT_M4A = 1 const val HIGH = 1 const val MEDIUM = 2 const val LOW = 3 const val ULTRA_LOW = 4 const val RES_HIGHEST = 0 const val RES_2160P = 1 const val RES_1440P = 2 const val RES_1080P = 3 const val RES_720P = 4 const val RES_480P = 5 const val RES_360P = 6 const val RES_LOWEST = 7 const val TEMPLATE_EXAMPLE = """--no-mtime -S "ext"""" const val TEMPLATE_SHORTCUTS = "template_shortcuts" const val TASK_LIST = "task_list" const val SAVED_LINKS = "saved_links" val paletteStyles = listOf( PaletteStyle.TonalSpot, PaletteStyle.Spritz, PaletteStyle.FruitSalad, PaletteStyle.Vibrant, PaletteStyle.Monochrome, ) const val STYLE_TONAL_SPOT = 0 const val STYLE_SPRITZ = 1 const val STYLE_FRUIT_SALAD = 2 const val STYLE_VIBRANT = 3 const val STYLE_MONOCHROME = 4 private val StringPreferenceDefaults = mapOf( SPONSORBLOCK_CATEGORIES to "default", MAX_RATE to "1000", SUBTITLE_LANGUAGE to "en.*,.*-orig", OUTPUT_TEMPLATE to DownloadUtil.OUTPUT_TEMPLATE_ID, CUSTOM_OUTPUT_TEMPLATE to DownloadUtil.OUTPUT_TEMPLATE_ID, ) private val BooleanPreferenceDefaults = mapOf( FORMAT_SELECTION to true, CONFIGURE to true, CELLULAR_DOWNLOAD to false, YT_DLP_AUTO_UPDATE to true, NOTIFICATION to true, EMBED_METADATA to true, USE_CUSTOM_AUDIO_PRESET to false, ) private val IntPreferenceDefaults = mapOf( TEMPLATE_ID to 0, CONCURRENT to 8, LANGUAGE to SYSTEM_DEFAULT, PALETTE_STYLE to 0, DARK_THEME_VALUE to DarkThemePreference.FOLLOW_SYSTEM, WELCOME_DIALOG to 1, AUDIO_CONVERSION_FORMAT to NOT_SPECIFIED, VIDEO_QUALITY to NOT_SPECIFIED, VIDEO_FORMAT to FORMAT_QUALITY, UPDATE_CHANNEL to STABLE, SHOW_SPONSOR_MSG to 0, CONVERT_SUBTITLE to NOT_SPECIFIED, DOWNLOAD_TYPE_INITIALIZATION to USE_PREVIOUS_SELECTION, YT_DLP_UPDATE_CHANNEL to YT_DLP_NIGHTLY, DOWNLOAD_TYPE to DownloadType.Video.ordinal, ) private val LongPreferenceDefaults = mapOf(YT_DLP_UPDATE_INTERVAL to DEFAULT_INTERVAL) fun String.getStringDefault() = StringPreferenceDefaults.getOrElse(this) { "" } object PreferenceUtil { private val kv: MMKV = MMKV.defaultMMKV() private val json = Json { ignoreUnknownKeys = true allowStructuredMapKeys = true } fun String.getInt(default: Int = IntPreferenceDefaults.getOrElse(this) { 0 }): Int = kv.decodeInt(this, default) fun String.getString( default: String = StringPreferenceDefaults.getOrElse(this) { "" } ): String = kv.decodeString(this) ?: default fun String.getBoolean( default: Boolean = BooleanPreferenceDefaults.getOrElse(this) { false } ): Boolean = kv.decodeBool(this, default) fun String.getLong(default: Long = LongPreferenceDefaults.getOrElse(this) { 0L }) = kv.decodeLong(this, default) fun String.updateString(newString: String) = kv.encode(this, newString) fun String.updateInt(newInt: Int) = kv.encode(this, newInt) fun String.updateLong(newLong: Long) = kv.encode(this, newLong) fun String.updateBoolean(newValue: Boolean) = kv.encode(this, newValue) fun updateValue(key: String, b: Boolean) = key.updateBoolean(b) fun encodeInt(key: String, int: Int) = key.updateInt(int) fun encodeString(key: String, string: String) = key.updateString(string) fun containsKey(key: String) = kv.containsKey(key) fun getAudioConvertFormat(): Int = AUDIO_CONVERSION_FORMAT.getInt() fun getVideoResolution(): Int = VIDEO_QUALITY.getInt() fun getAudioQuality(): Int = AUDIO_QUALITY.getInt() fun getVideoFormat(): Int = VIDEO_FORMAT.getInt() fun getAudioFormat(): Int = AUDIO_FORMAT.getInt() fun getDownloadType( usePreviousType: Boolean = DOWNLOAD_TYPE_INITIALIZATION.getInt() == USE_PREVIOUS_SELECTION ): DownloadType? { return if (usePreviousType) { DownloadType.entries.firstOrNull { it.ordinal == DOWNLOAD_TYPE.getInt() } ?: DownloadType.Video } else { null } } fun updateDownloadType(type: DownloadType) = DOWNLOAD_TYPE.updateInt(type.ordinal) fun isNetworkAvailableForDownload() = CELLULAR_DOWNLOAD.getBoolean() || !App.connectivityManager.isActiveNetworkMetered fun isAutoUpdateEnabled(): Boolean { return when { isFDroidBuild() -> false isDebugBuild() -> false else -> AUTO_UPDATE.getBoolean() } } @DeprecatedSinceApi(api = 33) fun getLocaleFromPreference(): Locale? { val languageCode = LANGUAGE.getInt() return LocaleLanguageCodeMap.entries.find { it.value == languageCode }?.key } fun saveLocalePreference(locale: Locale?) { if (Build.VERSION.SDK_INT >= 33) { // No op } else { LANGUAGE.updateInt(LocaleLanguageCodeMap[locale] ?: SYSTEM_DEFAULT) } } fun getConcurrentFragments(level: Int = CONCURRENT.getInt()): Float { return when (level) { 1 -> 0f 8 -> 0.33f 16 -> 0.66f else -> 1f } } fun getSponsorBlockCategories(): String = SPONSORBLOCK_CATEGORIES.getString() const val COOKIE_HEADER = "# Netscape HTTP Cookie File\n" + "# Auto-generated by Seal built-in WebView\n" val templateListStateFlow: StateFlow> = DatabaseUtil.getTemplateFlow() .stateIn(applicationScope, started = SharingStarted.Eagerly, emptyList()) private val List.selectedTemplate: CommandTemplate? get() = find { it.id == TEMPLATE_ID.getInt() } fun getTemplate(): CommandTemplate { var template: CommandTemplate? = null runBlocking { for (cnt in 1..5) { template = templateListStateFlow.value.selectedTemplate if (template != null) return@runBlocking delay(100) } } return template ?: throw NoSuchElementException() } suspend fun initializeTemplateSample() { TEMPLATE_ID.updateInt( DatabaseUtil.insertTemplate( CommandTemplate( id = 0, name = context.getString(R.string.custom_command_template), template = TEMPLATE_EXAMPLE, ) ) .toInt() ) } data class AppSettings( val darkTheme: DarkThemePreference = DarkThemePreference(), val isDynamicColorEnabled: Boolean = false, val seedColor: Int = DEFAULT_SEED_COLOR, val paletteStyleIndex: Int = 0, ) fun getMaxDownloadRate(): String = MAX_RATE.getString() private val mutableAppSettingsStateFlow = MutableStateFlow( AppSettings( DarkThemePreference( darkThemeValue = kv.decodeInt(DARK_THEME_VALUE, DarkThemePreference.FOLLOW_SYSTEM), isHighContrastModeEnabled = kv.decodeBool(HIGH_CONTRAST, false), ), isDynamicColorEnabled = kv.decodeBool(DYNAMIC_COLOR, DynamicColors.isDynamicColorAvailable()), seedColor = kv.decodeInt(THEME_COLOR, DEFAULT_SEED_COLOR), paletteStyleIndex = kv.decodeInt(PALETTE_STYLE, 0), ) ) val AppSettingsStateFlow = mutableAppSettingsStateFlow.asStateFlow() fun modifyDarkThemePreference( darkThemeValue: Int = AppSettingsStateFlow.value.darkTheme.darkThemeValue, isHighContrastModeEnabled: Boolean = AppSettingsStateFlow.value.darkTheme.isHighContrastModeEnabled, ) { applicationScope.launch(Dispatchers.IO) { mutableAppSettingsStateFlow.update { it.copy( darkTheme = AppSettingsStateFlow.value.darkTheme.copy( darkThemeValue = darkThemeValue, isHighContrastModeEnabled = isHighContrastModeEnabled, ) ) } kv.encode(DARK_THEME_VALUE, darkThemeValue) kv.encode(HIGH_CONTRAST, isHighContrastModeEnabled) } } fun modifyThemeSeedColor(colorArgb: Int, paletteStyleIndex: Int) { applicationScope.launch(Dispatchers.IO) { mutableAppSettingsStateFlow.update { it.copy(seedColor = colorArgb, paletteStyleIndex = paletteStyleIndex) } kv.encode(THEME_COLOR, colorArgb) kv.encode(PALETTE_STYLE, paletteStyleIndex) } } fun switchDynamicColor( enabled: Boolean = !mutableAppSettingsStateFlow.value.isDynamicColorEnabled ) { applicationScope.launch(Dispatchers.IO) { mutableAppSettingsStateFlow.update { it.copy(isDynamicColorEnabled = enabled) } kv.encode(DYNAMIC_COLOR, enabled) } } fun encodeTaskListBackup(map: Map) = runCatching { json.encodeToString>(map) } .onSuccess { kv.encode(TASK_LIST, it) } .onFailure { it.printStackTrace() } fun decodeTaskListBackup(): Map = runCatching { kv.decodeString(TASK_LIST)?.let { json.decodeFromString>(it) } } .onFailure { it.printStackTrace() } .getOrNull() ?: emptyMap() fun getSavedLinks(): Set = kv.decodeStringSet(SAVED_LINKS) ?: emptySet() fun updateSavedLinks(links: Set) = kv.encode(SAVED_LINKS, links) private const val TAG = "PreferenceUtil" } data class DarkThemePreference( val darkThemeValue: Int = FOLLOW_SYSTEM, val isHighContrastModeEnabled: Boolean = false, ) { companion object { const val FOLLOW_SYSTEM = 1 const val ON = 2 const val OFF = 3 } @Composable fun isDarkTheme(): Boolean { return if (darkThemeValue == FOLLOW_SYSTEM) isSystemInDarkTheme() else darkThemeValue == ON } @Composable fun getDarkThemeDesc(): String { return when (darkThemeValue) { FOLLOW_SYSTEM -> stringResource(R.string.follow_system) ON -> stringResource(R.string.on) else -> stringResource(R.string.off) } } } object PreferenceStrings { fun getSubtitleConversionFormat(subtitleFormat: Int = CONVERT_SUBTITLE.getInt()): String = when (subtitleFormat) { CONVERT_LRC -> context.getString(R.string.convert_to, "lrc") CONVERT_ASS -> context.getString(R.string.convert_to, "ass") CONVERT_SRT -> context.getString(R.string.convert_to, "srt") CONVERT_VTT -> context.getString(R.string.convert_to, "vtt") else -> context.getString(R.string.not_convert) } @Composable fun getAudioFormatDesc(audioFormatCode: Int = PreferenceUtil.getAudioFormat()): String = when (audioFormatCode) { M4A -> "M4A" OPUS -> "OPUS" else -> stringResource(R.string.not_specified) } @Composable fun getAudioQualityDesc(audioQualityCode: Int = PreferenceUtil.getAudioQuality()): String = when (audioQualityCode) { NOT_SPECIFIED -> stringResource(R.string.best_quality) HIGH -> "192 Kbps" MEDIUM -> "128 Kbps" LOW -> "64 Kbps" ULTRA_LOW -> "32 Kbps" else -> stringResource(R.string.lowest_bitrate) } @Composable fun getAudioConvertDesc(audioFormatCode: Int = PreferenceUtil.getAudioConvertFormat()): String { return when (audioFormatCode) { 0 -> stringResource(R.string.convert_to).format("mp3") else -> stringResource(R.string.convert_to).format("m4a") } } @Composable fun getVideoFormatDescComp(videoFormatCode: Int = PreferenceUtil.getVideoFormat()): String { return when (videoFormatCode) { FORMAT_COMPATIBILITY -> stringResource(R.string.prefer_compatibility_desc) FORMAT_QUALITY -> stringResource(R.string.prefer_quality_desc) else -> stringResource(R.string.not_specified) } } @Composable fun getVideoResolutionDesc( videoQualityCode: Int = PreferenceUtil.getVideoResolution() ): String { return when (videoQualityCode) { 1 -> "2160p" 2 -> "1440p" 3 -> "1080p" 4 -> "720p" 5 -> "480p" 6 -> "360p" 7 -> stringResource(R.string.lowest_quality) else -> stringResource(R.string.best_quality) } } @Composable fun getVideoFormatLabel(videoFormatPreference: Int = PreferenceUtil.getVideoFormat()): String { return when (videoFormatPreference) { FORMAT_COMPATIBILITY -> stringResource(id = R.string.legacy) else -> stringResource(id = R.string.quality) } } @Composable fun getUpdateIntervalText(interval: Long): String { return stringResource( id = when (interval) { INTERVAL_DAY -> R.string.every_day INTERVAL_WEEK -> R.string.every_week INTERVAL_MONTH -> R.string.every_month else -> R.string.disabled } ) } @Composable fun getAudioPresetText(preferences: DownloadUtil.DownloadPreferences): String { return with(preferences) { when { formatSorting -> { sortingFields } !useCustomAudioPreset -> { stringResource(R.string.best_quality) } convertAudio -> { when (audioConvertFormat) { CONVERT_MP3 -> stringResource(R.string.convert_to, "MP3") else -> stringResource(R.string.convert_to, "M4A") } } else -> { val preferredFormat = when (audioFormat) { M4A -> stringResource(R.string.prefer_placeholder, "M4A") OPUS -> stringResource(R.string.prefer_placeholder, "OPUS") else -> null } val preferredQuality = when (audioQuality) { NOT_SPECIFIED -> stringResource(R.string.best_quality) HIGH -> "192 Kbps" MEDIUM -> "128 Kbps" LOW -> "64 Kbps" ULTRA_LOW -> "32 Kbps" else -> stringResource(R.string.lowest_bitrate) } listOfNotNull(preferredFormat, preferredQuality).joinToString(separator = ", ") } } } } @Composable fun getVideoPresetText(preferences: DownloadUtil.DownloadPreferences): String { return with(preferences) { when { formatSorting -> { sortingFields } else -> { val preferredFormat = stringResource( id = R.string.prefer_placeholder, stringResource( id = if (videoFormat == FORMAT_QUALITY) R.string.quality else R.string.legacy ), ) val preferredResolution = getVideoResolutionDesc(videoResolution) listOf(preferredFormat, preferredResolution).joinToString(separator = ", ") } } } } } ================================================ FILE: app/src/main/java/com/junkfood/seal/util/SponsorData.kt ================================================ package com.junkfood.seal.util import kotlinx.serialization.Serializable @Serializable data class SponsorData(val data: Data) @Serializable data class Data(val viewer: Viewer) @Serializable data class Viewer(val sponsorshipsAsMaintainer: SponsorshipsAsMaintainer) @Serializable data class SponsorshipsAsMaintainer(val nodes: List) @Serializable data class SponsorEntity( val login: String, val name: String? = null, val websiteUrl: String? = null, val socialAccounts: SocialAccounts? = null, ) @Serializable data class Tier(val monthlyPriceInDollars: Int) @Serializable data class SponsorShip(val sponsorEntity: SponsorEntity, val tier: Tier? = null) @Serializable data class SocialAccounts(val nodes: List? = null) @Serializable data class SocialAccount(val displayName: String?, val url: String?) ================================================ FILE: app/src/main/java/com/junkfood/seal/util/SponsorUtil.kt ================================================ package com.junkfood.seal.util import android.util.Base64 import android.util.Log import androidx.annotation.CheckResult import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody object SponsorUtil { private const val TAG = "SponsorUtil" private const val MAGIC_STRING_0 = "Z2hwX2F0cFlT" private const val MAGIC_STRING_1 = "ZWtJQzFXb0JoQnBlYmlFbWI2TEZF" private const val MAGIC_STRING_2 = "NXJDNzNvZndQVw" // pls don't abuse private val magicString = Base64.decode(MAGIC_STRING_0 + MAGIC_STRING_1 + MAGIC_STRING_2, Base64.DEFAULT) .toString(Charsets.UTF_8) private val body = """ { "query": "query { viewer { sponsorshipsAsMaintainer(first: 100) { nodes { sponsorEntity { ... on User { login name websiteUrl socialAccounts(first: 4) { nodes { displayName url } } } ... on Organization { login name websiteUrl } } tier { monthlyPriceInDollars } } } } }" } """ .toRequestBody("application/json".toMediaType()) private val request = Request.Builder() .url("https://api.github.com/graphql") .post(body) .addHeader("Authorization", "bearer $magicString") .build() private val client = OkHttpClient() private val jsonFormat = Json { ignoreUnknownKeys = true } private var sponsorData: SponsorData? = null @CheckResult fun getSponsors(): Result = client .runCatching { sponsorData ?: jsonFormat .decodeFromString( newCall(request).execute().body.string().also { Log.d(TAG, it) } ) .apply { sponsorData = this } } .onFailure { it.printStackTrace() } } ================================================ FILE: app/src/main/java/com/junkfood/seal/util/TextUtil.kt ================================================ package com.junkfood.seal.util import android.content.Context import android.widget.Toast import androidx.annotation.MainThread import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.core.text.isDigitsOnly import com.junkfood.seal.App import com.junkfood.seal.App.Companion.applicationScope import com.junkfood.seal.App.Companion.context import com.junkfood.seal.R import java.util.regex.Pattern import kotlin.math.roundToInt import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Deprecated("Use extension functions of Context to show a toast") object ToastUtil { fun makeToast(text: String) { Toast.makeText(context.applicationContext, text, Toast.LENGTH_SHORT).show() } fun makeToastSuspend(text: String) { applicationScope.launch(Dispatchers.Main) { makeToast(text) } } fun makeToast(stringId: Int) { Toast.makeText(context.applicationContext, context.getString(stringId), Toast.LENGTH_SHORT) .show() } } @MainThread fun Context.makeToast(stringId: Int) { Toast.makeText(applicationContext, getString(stringId), Toast.LENGTH_SHORT).show() } @MainThread fun Context.makeToast(message: String) { Toast.makeText(applicationContext, message, Toast.LENGTH_SHORT).show() } private const val GIGA_BYTES = 1024f * 1024f * 1024f private const val MEGA_BYTES = 1024f * 1024f @Composable fun Number?.toFileSizeText(): String { if (this == null) return stringResource(id = R.string.unknown) return this.toFloat().run { if (this > GIGA_BYTES) stringResource(R.string.filesize_gb).format(this / GIGA_BYTES) else stringResource(R.string.filesize_mb).format(this / MEGA_BYTES) } } /** Convert time in **seconds** to `hh:mm:ss` or `mm:ss` */ fun Int.toDurationText(): String = this.run { if (this > 3600) "%d:%02d:%02d".format(this / 3600, (this % 3600) / 60, this % 60) else "%02d:%02d".format(this / 60, this % 60) } fun String.isNumberInRange(start: Int, end: Int): Boolean { return this.isNotEmpty() && this.isDigitsOnly() && this.length < 10 && this.toInt() >= start && this.toInt() <= end } private const val URL_REGEX_PATTERN = "(http|https)://[\\w\\-_]+(\\.[\\w\\-_]+)+([\\w\\-.,@?^=%&:/~+#]*[\\w\\-@?^=%&/~+#])?" fun String.isNumberInRange(range: IntRange): Boolean = this.isNumberInRange(range.first, range.last) fun ClosedFloatingPointRange.toIntRange() = IntRange(start.roundToInt(), endInclusive.roundToInt()) fun String?.toHttpsUrl(): String = this?.run { if (matches(Regex("^(http:).*"))) replaceFirst("http", "https") else this } ?: "" fun matchUrlFromClipboard(string: String, isMatchingMultiLink: Boolean = false): String { findURLsFromString(string, !isMatchingMultiLink).joinToString(separator = "\n").run { if (isEmpty()) ToastUtil.makeToast(R.string.paste_fail_msg) else ToastUtil.makeToast(R.string.paste_msg) return this } } fun matchUrlFromSharedText(s: String): String { findURLsFromString(s, true).joinToString(separator = "\n").run { if (isEmpty()) ToastUtil.makeToast(R.string.share_fail_msg) // else makeToast(R.string.share_success_msg) return this } } fun Number?.toBitrateText(): String { val br = this?.toFloat() ?: return "" return when { br <= 0f -> "" // i don't care br < 1024f -> "%.1f Kbps".format(br) else -> "%.2f Mbps".format(br / 1024f) } } fun getErrorReport(th: Throwable, url: String): String = App.getVersionReport() + "\nURL: ${url}\n${th.message}" @Deprecated( "Use findURLsFromString instead", ReplaceWith("findURLsFromString(s, !isMatchingMultiLink).joinToString(separator = \"\\n\")"), ) fun matchUrlFromString(s: String, isMatchingMultiLink: Boolean = false): String = findURLsFromString(s, !isMatchingMultiLink).joinToString(separator = "\n") fun findURLsFromString(input: String, firstMatchOnly: Boolean = false): List { val result = mutableListOf() val pattern = Pattern.compile(URL_REGEX_PATTERN) with(pattern.matcher(input)) { if (!firstMatchOnly) { while (find()) { result += group() } } else { if (find()) result += (group()) } } return result } fun connectWithDelimiter(vararg strings: String?, delimiter: String): String = strings .toList() .filter { !it.isNullOrBlank() } .joinToString(separator = delimiter) { it.toString() } fun connectWithBlank(s1: String, s2: String): String { val blank = if (s1.isEmpty() || s2.isEmpty()) "" else " " return s1 + blank + s2 } ================================================ FILE: app/src/main/java/com/junkfood/seal/util/UpdateUtil.kt ================================================ package com.junkfood.seal.util import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.util.Log import androidx.core.content.FileProvider import com.junkfood.seal.App import com.junkfood.seal.App.Companion.context import com.junkfood.seal.R import com.junkfood.seal.util.FileUtil.getFileProvider import com.junkfood.seal.util.PreferenceUtil.getInt import com.junkfood.seal.util.PreferenceUtil.updateLong import com.yausername.youtubedl_android.YoutubeDL import java.io.File import java.util.regex.Pattern import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.withContext import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.ResponseBody object UpdateUtil { private const val OWNER = "JunkFood02" private const val REPO = "Seal" private const val ARM64 = "arm64-v8a" private const val ARM32 = "armeabi-v7a" private const val X86 = "x86" private const val X64 = "x86_64" private const val TAG = "UpdateUtil" private val client = OkHttpClient() private val requestForLatestRelease = Request.Builder() .url("https://api.github.com/repos/${OWNER}/${REPO}/releases/latest") .build() private val requestForReleases = Request.Builder().url("https://api.github.com/repos/${OWNER}/${REPO}/releases").build() private const val ytdlpNightlyBuildRelease = "https://api.github.com/repos/yt-dlp/yt-dlp-nightly-builds/releases/latest" private val jsonFormat = Json { ignoreUnknownKeys = true } suspend fun updateYtDlp(): YoutubeDL.UpdateStatus? = withContext(Dispatchers.IO) { val channel = when (YT_DLP_UPDATE_CHANNEL.getInt()) { YT_DLP_NIGHTLY -> YoutubeDL.UpdateChannel.NIGHTLY else -> YoutubeDL.UpdateChannel.STABLE } YoutubeDL.getInstance() .updateYoutubeDL(appContext = context, updateChannel = channel) .also { if (it == YoutubeDL.UpdateStatus.DONE) { YoutubeDL.getInstance().version(context)?.let { PreferenceUtil.encodeString(YT_DLP_VERSION, it) } } val now = System.currentTimeMillis() YT_DLP_UPDATE_TIME.updateLong(now) } } private fun getLatestRelease(): Release = client.newCall(requestForReleases).execute().body.use { val releaseList = jsonFormat.decodeFromString>(it.string()) val stable = UPDATE_CHANNEL.getInt() == STABLE val latestRelease = releaseList .filter { if (stable) it.name.toVersion() is Version.Stable else true } .maxByOrNull { it.name.toVersion() } ?: throw Exception("null response") latestRelease } fun checkForUpdate(context: Context = App.context): Release? { val currentVersion = context.getCurrentVersion() val latestRelease = getLatestRelease() val latestVersion = latestRelease.name.toVersion() return if (currentVersion < latestVersion) latestRelease else null } private fun Context.getCurrentVersion(): Version = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { packageManager .getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) .versionName .toVersion() } else { packageManager.getPackageInfo(packageName, 0).versionName.toVersion() } private fun Context.getLatestApk() = File(getExternalFilesDir("apk"), "latest.apk") fun installLatestApk(context: Context = App.context) = context.run { kotlin .runCatching { val contentUri = FileProvider.getUriForFile(this, getFileProvider(), getLatestApk()) val intent = Intent(Intent.ACTION_VIEW).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) setDataAndType(contentUri, "application/vnd.android.package-archive") } startActivity(intent) } .onFailure { throwable: Throwable -> throwable.printStackTrace() ToastUtil.makeToast(R.string.app_update_failed) } } suspend fun deleteOutdatedApk(context: Context = App.context) = context.runCatching { val apkFile = getLatestApk() if (apkFile.exists()) { val apkVersion = context.packageManager .getPackageArchiveInfo(apkFile.absolutePath, 0) ?.versionName .toVersion() if (apkVersion <= context.getCurrentVersion()) { apkFile.delete() } } } suspend fun downloadApk( context: Context = App.context, release: Release, ): Flow = withContext(Dispatchers.IO) { val apkVersion = context.packageManager .getPackageArchiveInfo(context.getLatestApk().absolutePath, 0) ?.versionName .toVersion() Log.d(TAG, apkVersion.toString()) if (apkVersion >= release.name.toVersion()) { return@withContext flow { emit(DownloadStatus.Finished(context.getLatestApk())) } } val abiList = Build.SUPPORTED_ABIS val preferredArch = abiList.firstOrNull() ?: return@withContext emptyFlow() val targetUrl = release.assets ?.find { return@find it.name?.contains(preferredArch) ?: false } ?.browserDownloadUrl ?: return@withContext emptyFlow() val request = Request.Builder().url(targetUrl).build() try { val response = client.newCall(request).execute() val responseBody = response.body return@withContext responseBody.downloadFileWithProgress(context.getLatestApk()) } catch (e: Exception) { e.printStackTrace() } emptyFlow() } private fun ResponseBody.downloadFileWithProgress(saveFile: File): Flow = flow { emit(DownloadStatus.Progress(0)) var deleteFile = true try { byteStream().use { inputStream -> saveFile.outputStream().use { outputStream -> val totalBytes = contentLength() val data = ByteArray(8_192) var progressBytes = 0L while (true) { val bytes = inputStream.read(data) if (bytes == -1) { break } outputStream.channel outputStream.write(data, 0, bytes) progressBytes += bytes emit( DownloadStatus.Progress( percent = ((progressBytes * 100) / totalBytes).toInt() ) ) } when { progressBytes < totalBytes -> throw Exception("missing bytes") progressBytes > totalBytes -> throw Exception("too many bytes") else -> deleteFile = false } } } emit(DownloadStatus.Finished(saveFile)) } finally { if (deleteFile) { saveFile.delete() } } } .flowOn(Dispatchers.IO) .distinctUntilChanged() @Serializable data class Release( @SerialName("html_url") val htmlUrl: String? = null, @SerialName("tag_name") val tagName: String? = null, val name: String? = null, val draft: Boolean? = null, @SerialName("prerelease") val preRelease: Boolean? = null, @SerialName("created_at") val createdAt: String? = null, @SerialName("published_at") val publishedAt: String? = null, val assets: List? = null, val body: String? = null, ) @Serializable data class AssetsItem( val name: String? = null, @SerialName("content_type") val contentType: String? = null, val size: Int? = null, @SerialName("download_count") val downloadCount: Int? = null, @SerialName("created_at") val createdAt: String? = null, @SerialName("updated_at") val updatedAt: String? = null, @SerialName("browser_download_url") val browserDownloadUrl: String? = null, ) sealed class DownloadStatus { object NotYet : DownloadStatus() data class Progress(val percent: Int) : DownloadStatus() data class Finished(val file: File) : DownloadStatus() } private val pattern = Pattern.compile("""v?(\d+)\.(\d+)\.(\d+)(-(\w+)\.(\d+))?""") private val EMPTY_VERSION = Version.Stable() fun String?.toVersion(): Version = this?.run { val matcher = pattern.matcher(this) if (matcher.find()) { val major = matcher.group(1)?.toInt() ?: 0 val minor = matcher.group(2)?.toInt() ?: 0 val patch = matcher.group(3)?.toInt() ?: 0 val buildNumber = matcher.group(6)?.toInt() ?: 0 when (matcher.group(5)) { "alpha" -> Version.Alpha(major, minor, patch, buildNumber) "beta" -> Version.Beta(major, minor, patch, buildNumber) "rc" -> Version.ReleaseCandidate(major, minor, patch, buildNumber) else -> Version.Stable(major, minor, patch) } } else EMPTY_VERSION } ?: EMPTY_VERSION sealed class Version(val major: Int, val minor: Int, val patch: Int, val build: Int = 0) : Comparable { companion object { // private const val ABI = 1L private const val BUILD = 10L private const val VARIANT = 100L private const val PATCH = 10_000L private const val MINOR = 1_000_000L private const val MAJOR = 100_000_000L private const val STABLE = VARIANT * 4 private const val ALPHA = VARIANT * 1 private const val BETA = VARIANT * 2 private const val RELEASE_CANDIDATE = VARIANT * 3 } abstract fun toVersionName(): String abstract fun toNumber(): Long class Alpha( versionMajor: Int = 0, versionMinor: Int = 0, versionPatch: Int = 0, versionBuild: Int = 0, ) : Version(versionMajor, versionMinor, versionPatch, versionBuild) { override fun toVersionName(): String = "${major}.${minor}.${patch}-alpha.$build" override fun toNumber(): Long = major * MAJOR + minor * MINOR + patch * PATCH + build * BUILD + ALPHA } class Beta(versionMajor: Int, versionMinor: Int, versionPatch: Int, versionBuild: Int) : Version(versionMajor, versionMinor, versionPatch, versionBuild) { override fun toVersionName(): String = "${major}.${minor}.${patch}-beta.$build" override fun toNumber(): Long = major * MAJOR + minor * MINOR + patch * PATCH + build * BUILD + BETA } class ReleaseCandidate( versionMajor: Int, versionMinor: Int, versionPatch: Int, versionBuild: Int, ) : Version(versionMajor, versionMinor, versionPatch, versionBuild) { override fun toVersionName(): String = "${major}.${minor}.${patch}-rc.$build" override fun toNumber(): Long = major * MAJOR + minor * MINOR + patch * PATCH + build * BUILD + RELEASE_CANDIDATE } class Stable(versionMajor: Int = 0, versionMinor: Int = 0, versionPatch: Int = 0) : Version(versionMajor, versionMinor, versionPatch) { override fun toVersionName(): String = "${major}.${minor}.${patch}" override fun toNumber(): Long = major * MAJOR + minor * MINOR + patch * PATCH + build * BUILD + STABLE // Prioritize stable versions } override operator fun compareTo(other: Version): Int = this.toNumber().compareTo(other.toNumber()) } } ================================================ FILE: app/src/main/java/com/junkfood/seal/util/VideoInfo.kt ================================================ package com.junkfood.seal.util import kotlin.math.roundToInt import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable sealed interface YoutubeDLInfo @Serializable data class VideoInfo( val id: String = "", val title: String = "", val formats: List? = emptyList(), // val thumbnails: List = emptyList(), val thumbnail: String? = null, val description: String? = null, val uploader: String? = null, @SerialName("uploader_id") val uploaderId: String? = null, val subtitles: Map> = emptyMap(), @SerialName("automatic_captions") val automaticCaptions: Map> = emptyMap(), // @SerialName("uploader_id") val uploaderId: String? = null, // @SerialName("uploader_url") val uploaderUrl: String? = null, // @SerialName("channel_id") val channelId: Int? = null, // @SerialName("channel_url") val channelUrl: String? = null, val duration: Double? = null, @SerialName("view_count") val viewCount: Long? = null, @SerialName("webpage_url") val webpageUrl: String? = null, // @SerialName("categories") val categories: List = emptyList(), val tags: List? = emptyList(), @SerialName("live_status") val liveStatus: String? = null, // @SerialName("release_timestamp") val releaseTimestamp: Int? = null, @SerialName("comment_count") val commentCount: Int? = null, val chapters: List? = null, @SerialName("like_count") val likeCount: Int? = null, val channel: String? = null, // @SerialName("channel_follower_count") val channelFollowerCount: Int? = null, @SerialName("upload_date") val uploadDate: String? = null, val availability: String? = null, @SerialName("original_url") val originalUrl: String? = null, @SerialName("webpage_url_basename") val webpageUrlBasename: String? = null, @SerialName("webpage_url_domain") val webpageUrlDomain: String? = null, val extractor: String? = null, @SerialName("extractor_key") val extractorKey: String = "", val playlist: String? = null, @SerialName("playlist_index") val playlistIndex: Int? = null, @SerialName("display_id") val displayId: String? = null, val fulltitle: String? = null, @SerialName("duration_string") val durationString: String? = null, @SerialName("release_date") val releaseDate: String? = null, val format: String? = null, @SerialName("format_id") val formatId: String? = null, val ext: String = "", val protocol: String? = null, @SerialName("format_note") val formatNote: String? = null, @SerialName("filesize_approx") val fileSizeApprox: Double? = null, @SerialName("filesize") val fileSize: Double? = null, val tbr: Double? = null, val width: Double? = null, val height: Double? = null, val resolution: String? = null, val fps: Double? = null, @SerialName("dynamic_range") val dynamicRange: String? = null, val vcodec: String? = null, val vbr: Double? = null, val acodec: String? = null, val abr: Double? = null, val asr: Int? = null, val epoch: Int? = null, @SerialName("requested_downloads") val requestedDownloads: List? = null, @SerialName("requested_formats") val requestedFormats: List? = null, val filename: String? = null, @SerialName("_type") val type: String? = null, ) : YoutubeDLInfo @Serializable data class Format( @SerialName("format_id") val formatId: String? = null, @SerialName("format_note") val formatNote: String? = null, val ext: String? = null, @SerialName("acodec") val acodec: String? = null, @SerialName("vcodec") val vcodec: String? = null, val url: String? = null, val width: Double? = null, val height: Double? = null, val fps: Double? = null, @SerialName("audio_ext") val audioExt: String? = null, @SerialName("video_ext") val videoExt: String? = null, val format: String? = null, val resolution: String? = null, val vbr: Double? = null, val abr: Double? = null, val tbr: Double? = null, @SerialName("filesize") val fileSize: Double? = null, @SerialName("filesize_approx") val fileSizeApprox: Double? = null, ) { fun isAudioOnly(): Boolean = vcodec == null || vcodec == "none" fun isVideoOnly(): Boolean = acodec == null || acodec == "none" fun containsVideo(): Boolean = vcodec != null && vcodec != "none" fun containsAudio(): Boolean = acodec != null && acodec != "none" } @Serializable data class VideoClip(val start: Int = 0, val end: Int = 0) { constructor( range: ClosedFloatingPointRange ) : this(range.start.roundToInt(), range.endInclusive.roundToInt()) } @Serializable data class Chapter( val title: String? = null, @SerialName("start_time") val startTime: Double? = null, @SerialName("end_time") val endTime: Double? = null, ) @Serializable data class RequestedDownload( @SerialName("requested_formats") val requestedFormats: List? = emptyList(), @SerialName("format_id") val formatId: String? = null, @SerialName("format_note") val formatNote: String? = null, val ext: String? = null, @SerialName("acodec") val acodec: String? = null, @SerialName("vcodec") val vcodec: String? = null, val url: String? = null, val width: Double? = null, val height: Double? = null, val fps: Double? = null, @SerialName("audio_ext") val audioExt: String? = null, @SerialName("video_ext") val videoExt: String? = null, val format: String? = null, val resolution: String? = null, val vbr: Double? = null, val abr: Double? = null, val tbr: Double? = null, @SerialName("filesize") val fileSize: Double? = null, @SerialName("filesize_approx") val fileSizeApprox: Double? = null, val filename: String? = null, ) { fun toFormat(): Format = Format( formatId = formatId, formatNote = formatNote, ext = ext, acodec = acodec, vcodec = vcodec, url = url, width = width, height = height, fps = fps, audioExt = audioExt, videoExt = videoExt, format = format, resolution = resolution, vbr = vbr, abr = abr, tbr = tbr, fileSize = fileSize, fileSizeApprox = fileSizeApprox, ) } @Serializable data class PlaylistResult( val uploader: String? = null, val availability: String? = null, val channel: String? = null, val title: String? = null, val description: String? = null, @SerialName("_type") val type: String? = null, val entries: List? = emptyList(), @SerialName("webpage_url") val webpageUrl: String? = null, @SerialName("original_url") val originalUrl: String? = null, @SerialName("extractor_key") val extractorKey: String? = null, ) : YoutubeDLInfo @Serializable data class Thumbnail(val url: String, val height: Double = .0, val width: Double = .0) @Serializable data class PlaylistEntry( @SerialName("_type") val type: String? = null, val ieKey: String? = null, val id: String? = null, val url: String? = null, val title: String? = null, val duration: Double? = .0, val uploader: String? = null, val channel: String? = null, val thumbnails: List? = emptyList(), ) @Serializable data class SubtitleFormat( val ext: String, val url: String, val name: String? = null, val protocol: String? = null, ) ================================================ FILE: app/src/main/res/drawable/ic_launcher_foreground.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_monochrome.xml ================================================ ================================================ FILE: app/src/main/res/drawable/icons8_matrix.xml ================================================ ================================================ FILE: app/src/main/res/drawable/icons8_telegram_app.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_cancel_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_content_copy_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/seal.xml ================================================ ================================================ FILE: app/src/main/res/drawable-anydpi-v24/ic_stat_seal.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml ================================================ ================================================ FILE: app/src/main/res/resources.properties ================================================ unqualifiedResLocale=en-US ================================================ FILE: app/src/main/res/values/ic_launcher_background.xml ================================================ #FFFFFF ================================================ FILE: app/src/main/res/values/strings.xml ================================================ Seal Video folder Save as audio Save thumbnail Settings General, format, custom command Download The link cannot be empty Download and save audio, instead of video Save video thumbnail as a file Using the latest version of yt-dlp Could not install the latest yt-dlp version. Please make sure you are connected to the Internet. Fetching video info… Permission denied Download finished Could not download file Download \"%1$s\" Could not fetch video info General Display language Set display language An existing download task is already running Paste URL Could not match the URL in the clipboard Yt-dlp version Click to install the latest yt-dlp version Update yt-dlp Remove? Remove \"%1$s\" from your download history for good? Confirm Cancel Downloads Audio Link copied to clipboard Open link Remove Delete file About Version, feedback, auto update Back Version Look for changelogs and new versions Latest release Check the GitHub repository and the README README Video Checked Credits Credits and libre software Custom command Run yt-dlp command with custom template Command template Edit Start executing command Advanced Detailed output Print detailed messages when downloading Display Dark theme, dynamic color, languages Dark theme System On Off Cancel Configure before download Configure preferences before downloading Adjust this download Error report copied to clipboard Thumbnail Paste Yt-dlp usage references Output path and URL will be added by the app. Convert audio format Unconverted Convert to %1$s Format Re-encoding audio files will cause loss in audio quality and increase in file size. Video quality Best quality Limit the video quality when multiple are present Not specified (default) Preferred video format Preferred format when multiple are provided Video format Convert Download Close Don\'t show up again User guide Open settings Click \"Paste\" to get video link from your clipboard. Then click \"Download\" after adjusting its settings. Check and manage in-app downloads, including videos and audio files. Take a look at the download settings and make sure you have the latest version of yt-dlp before using it. Download playlist Download multiple videos from a playlist Default Download Notify of downloaded files and progress Video link Download finished. Tap to open. Running custom commands… "Please set battery usage of this app to \"Unrestricted\" in the system settings to download in the background." Multi-threaded download Download more parts of M3U8/MPD videos in parallel %d thread(s) would be used to download DASH/HLS native video concurrently. Options Additional settings Unable to match URL from shared content Reading video link from shared content… Show more actions Download notification "Notify of downloaded files and progress" Fetching playlist info… Playlist selection "Specify the range of videos to download from the playlist \"%3$s\" (from %1$d to %2$d)." Start End Invalid index range Downloading playlist (%1$d/%2$d)… Audio folder Download directory Select where to store videos and audio files Save to subdirectory Save files in folders named as respective fields Storage permission issue Directories outside Download/ and Documents/ are not supported Battery configuration Ignore battery optimization for this app to download in the background Seal is downloading… Unknown error Translate Help translate this app on Hosted Weblate Prefix Embed subtitles Embed soft-subs into videos if available New template Label Remove? Remove \"%1$s\" from command templates for good? Template selection Edit and manage command templates Download in progress… Download task canceled GitHub issue Submit an issue for bug report or feature request Info copied to clipboard Enqueued Finished Downloading Canceled Fetching info Open file Restart Error Copy link Copy report Video resolution Video file size Export to clipboard Import from clipboard Exported %1$d template(s) Imported %1$d template(s) %1$d Download tasks Recently Added %1$d video(s), %2$d audio file(s) Remove %1$d item(s) from your download history for good? SponsorBlock Remove or mark segments in videos with SponsorBlock API Specify the SponsorBlock categories to remove or mark in the video file SponsorBlock categories Check for updates Automatically check for the latest version on GitHub The current version is up to date Failed to update to the latest version Update Aria2 Use aria2c as the external downloader Cookies Use Netscape formatted cookies for downloads cookies.txt Clear temporary files Delete all temporary files from the temporary directory Deleted %1$d temporary file(s) Temporary files can be used to resume cancelled downloads. Are you sure to delete all of these files?\n\nYou can access these files in %1$s Multiselect mode Incognito Disable download history Dynamic color Apply colors from wallpapers to the app theme Download using cellular Allow downloading media when connected to metered networks Downloading with cellular network is disabled according to your settings This file is no longer available Network Rate limit Limit the maximum download speed Maximum rate High contrast dark theme Invalid input Lowest quality Download queue Unavailable File format, video quality, subtitles Yt-dlp version, notification, playlist Rate limit, downloader, cookies Disable preview No display of thumbnails during download Privacy Use custom command Private directory Store downloads in a hidden directory Crop artwork Crop embedded image into square Select videos to download from the playlist \"%1$s\" Select all %1$d selected Video (no audio) Suggested Format selection Select the format to download before starting the download Generate new cookies Use Cookies Remove this entry for \"%1$s\"? Please note that the cookies stored for this site will not be cleared. Some options are unavailable when using custom command How does it work? Downloading from some sites requires account authentication information. Click \"Generate new cookies\", enter the URL of the website and then log in with your account in the browser page, the app will generate it for you. yt-dlp is a powerful command-line tool for downloading videos. Seal makes it easier to use yt-dlp by providing an intuitive GUI, presets for common commands, and other additional features.\n\nFor advanced usage of yt-dlp, Seal allows you to create, save and execute custom command templates directly, just like in a terminal.\n\nWhen using custom commands, most of the GUI options and features would be disabled. Telegram channel Matrix space SD card folder Automatic captions Download auto-generated captions Quick download Most video streaming platforms deliver audio and video separately, you can select and merge an audio-only format with a video-only format to a single video. Video title sample text Video creator sample text Subtitle Download subtitles Subtitle languages Languages, embed subtitles, auto captions Copy log Clear Edit shortcuts Add Shortcuts Edit the custom shortcuts that can be used to compose command templates. Running tasks Show log Log Subtitles may be mistimed when removing SponsorBlock segments. To embed soft subtitles, videos will be remuxed into mkv container. You can use VLC Media Player or other compatible apps to watch videos with soft subtitles. %.2f MB %.2f GB Share Stable Preview Install pre-release builds to preview new features and changes.\n\nThere will be some instability in these versions, so please don\'t hesitate to give us feedback if you experience any problems to help us improve the app for the future. Update channel Auto update Enable auto update Discard Apply Clip video Start End Preferred audio format Unlimited Lowest bitrate Audio quality Limit audio bitrate when multiple qualities are present Format sorting Sorting formats with the -S option of yt-dlp Import Title Rename second minute Clear all cookies Delete all the cookies stored in the app for good? Store temporary files in the internal directory Sponsor Support this app by sponsoring on GitHub Seal will be always free and open source for everyone. If you like it, please consider sponsoring me on GitHub! Feedback Sponsors Audio format No downloaded media Beta Enable experimental feature? Downloads using this feature will be delegated to FFmpeg to download selected sections of the video, this feature is still experimental and the cutting will not be completely accurate, not all formats support this feature and you may experience slower download speeds. Make video clips in the format selection page Embed thumbnail Embed thumbnails into videos Auto-update is not available for %1$s builds. If you don\'t have %1$s installed on your device, or would like to preview upcoming new features in Seal, please consider %2$s. switching to GitHub builds Okay Got it Feature not available No custom command tasks Message from developer Thank you very much! Download videos from the URL Convert subtitles Convert the subtitles to another format Split video Video will be split into %1$d chapters Oops! Something went wrong Copy and exit Expand New download task Start Edit \"%1$s\" Proxy Use proxy for internet connections Legacy Quality Enable notifications? The app needs your permission to post notifications about download status and progress. Disable Tap to set up directory Custom command directory Disabled Folder picker Specify the output directory when using custom commands Prefer MP4(H.264) formats for sharing to other apps Prefer AV1, VP9 or H.265 formats for watching in compatible apps Download type Custom Auto Commands Format preference Learn more Unknown Tap to open webpage for generating new cookies: Remove %1$s from command templates for good? User-Agent header Export to file Output template Presets Specify the template for output file names Download archive Record downloaded video IDs in an archive to avoid duplicate downloads Clear download archive? Remove %1$s in the archive file for good? Embed metadata Embed metadata and video thumbnail into the audio file Required Show all %1$d items Edit file Save Use format sorting Restrict filenames Limit filenames to specific characters to ensure compatibility Website Playlist title Your downloads will be saved as: System settings Force IPv4 Make all connections via IPv4 Keep subtitle files Allow once Allow always Don\'t allow Allow downloading with cellular? Merge multiple audio stream Allow multiple audio streams to be merged into a single file Search in downloads Search Auto-translated subtitles Auto-translated subtitles for all languages will be available in downloads. These subtitles may be inaccurate and difficult to understand. Language of the subtitles to download in Auto format selection, separated by commas. Remember for next download Use previous selection None Reset Search in subtitles No thanks The following languages will be added to your preference for future downloads: Update subtitle languages? Export Import Full backup Backup type Export to File Clipboard Import from Export download history? Import download history? Downloaded files won\'t be imported. You\'ll need to download them back manually Exporting %1$s from download history. Downloaded files and preferences won\'t be backed up. Download history Imported %1$s to download history Redownload The video has been downloaded. If this is not the expected behavior, please check your download archive. Remux video container Remux videos into MKV container for better compatibility %1$d cookies from %2$d websites in total Every day Every week Every month All languages Playlist Continue Preset Prefer %1$s Choose from formats, subtitles, and customize further Download automatically using your format preferences Edit preset Download the best available format Task added to queue You\'ll find your downloads here Tap on the download button or share a video link to this app to start a download Downloaded All Select from %1$d links Show navigation drawer Resume Delete Media info Troubleshooting Issue tracker Fix common errors and check for known issues Encountered an error? Before reporting a new issue, please search our issue tracker. Many common problems have already been addressed and documented there. Saved links Add new link Add to %1$s %d item %d items %d video %d videos %d audio %d audios ================================================ FILE: app/src/main/res/values/themes.xml ================================================ ================================================ FILE: app/src/main/res/values-ar/strings.xml ================================================ حفظ كملف صوت تنزيل الصورة المصغرة عام، التنسيق، أمر مخصص لا يمكن أن يكون الحقل فارغاً قم بتنزيل الصوت وحفظه بدلاً من الفيديو تنزيل الصورة المصغرة للفيديو كملف جارٍ جلب معلومات الفيديو… اكتمل التحميل عام لغة العرض تحميل \"%1$s\" الصق عنوان URL تعذر مطابقة الرابط الموجود في الحافظة إصدار Yt-dlp إزالة؟ هل تريد إزالة \"%1$s\" من سجل التنزيلات بشكل دائم؟ نعم إلغاء التحميلات افتح الرابط إزالة حذف المِلَفّ حول الإصدار، التعليقات، التحديث التلقائي الإصدار ألق نظرة على الإصدارات الجديدة وسجل التغييرات أحدث إصدار فيديو محدد قالب الأوامر تحرير تقرير مفصل أظهر تقارير مفصلة عند التنزيل شاشة العرض المظهر الداكن، اللون الديناميكي، اللغات مظهر داكن نظام تشغيل إيقاف ضبط الإعدادات قبل التنزيل خصص هذا التحميل صورة مصغرة للفيديو لصق مراجع استخدام yt-dlp التحويل إلى %1$s الصيغ تحويل صيغة الصوت قم بالحد من جودة الفيديو عند تواجد العديد صيغة الفيديو المفضلة التنسيق المُفضل في حال توفر عدة خيارات صيغة الفيديو تحويل تحميل دليل المستخدم افتح الإعدادات انقر فوق «لصق» للحصول على رابط الفيديو من الحافظة الخاصة بك. تنزيل قائمة التشغيل قم بتنزيل مقاطع فيديو متعددة من قائمة التشغيل تنزيل تم التحميل، انقر للعرض. تشغيل الأوامر المخصصة … قم بتنزيل المزيد من أجزاء مقاطع الفيديو M3U8 / MPD بالتوازي %d من الممكن استخدام سلسلة لتنزيل فيديو DASH / HLS الأصلي بشكل متزامن. خيارات عرض مزيدٍ من الإجراءات إشعار التنزيل إشعار بالملفات التي تم تنزيلها ومستوى التقدم اختيار قائمة التشغيل حدد نطاق مقاطع الفيديو المراد تنزيلها من قائمة التشغيل «%3$s» (من %1$d إلى %2$d). نطاق الفهرس غير صالح مجلد الصوت مدير التحميل حفظ في مجلد فرعي مشكلة أذونات التخزين المجلدات خارج «التنزيل» و«المستندات» غير مدعومة إدارة البطارية تجاهل تحسين البطارية لجعل هذا التطبيق يحمل في الخلفية ترجم ساعد في ترجمة هذا التطبيق على Weblate مجلد الفيديو الإعدادات تنزيل أنت على أحدث إصدار من yt-dlp تعذر تثبيت أحدث إصدار من yt-dlp. يرجى التحقق من اتصالك بالإنترنت. تم رفض الإذن تعذر تحميل الملف فشل جلب معلومات الفيديو تعيين لغة العرض هناك مهمة تنزيل جارية بالفعل انقر هنا لتثبيت آخر إصدار من yt-dlp تم نسخ الرابط إلى الحافظة الصوت رجوع انظُر لمستودع GitHub وREADME استعمِل yt-dlp بأوامرك المخصصة شكر وتقدير شكر وتقدير والبرمجيات الحرة الأوامر المخصصة بدء تنفيذ الأمر إعدادات متقدمة لا تظهر مرة أخرى غير محول إعدادات إضافية إعادة ترميز ملفات الصوت يُخفِّضُ الجودة ويزيد الحجم. جودة الفيديو أفضل جودة غير محدد (افتراضي) ثم انقر فوق «تنزيل» بعد ضبط إعداداته. أظهر إشعارًا للملفات التي يتم تحميلها ومستوى التقدم يرجى ضبط استخدام البطارية لهذا التطبيق على «غير مقيد» في إعدادات النظام للتنزيل في الخلفية. إغلاق اِفتراضي التحقق من التنزيلات داخل التطبيق وإدارتها، بما في ذلك ملفات الفيديو والصوت. ألق نظرة على إعدادات التنزيل وتأكد من أن لديك أحدث إصدار من yt-dlp قبل استخدامه. رابط الفيديو تنزيل متعدد الأجزاء احفظ الملفات في مجلدات بأسماء الحقول المعنية خطأ غير معروف تعذر مطابقة عنوان URL من المحتوى المشترك جارٍ قراءة رابط الفيديو من المحتوى المشترك … بداية نهاية جارٍ تنزيل قائمة التشغيل (%1$d/%2$d)… حدد مكان تخزين ملفات الفيديو والصوت إلغاء Seal يقوم بالتنزيل… اضبط التفضيلات قبل التنزيل نُسِخَ تقرير الخطأ إلى الحافظة سيضيف التطبيق مسار الإخراج وعنوان الرابط. جارٍ جلبُ معلومات قائمة التشغيل … قالب المسار دمج الترجمة تضمين الترجمة المقدمة في الفيديوهات إذا كانت متوفرة قالب جديد اسم القالب إزالة؟ هل تريد إزالة «%1$s» من قوالب الأوامر للأبد؟ اختيار القالب تحرير وإدارة قوالب الأوامر التنزيل قيد التقدم… تم إلغاء مهمة التنزيل تذكرة Github أرسل تذكرة للإبلاغ عن خطأ أو طلب ميزة نُسِخَتِ المعلومات إلى الحافظة في قائمة الانتظار انتهى جارٍ التحميل تم الإلغاء جارٍ إحضار المعلومات إعادة بدء افتح الملف خطأ نسخ الرابط نسخ التقرير حجم ملف الفيديو دقة الفيديو تصدير إلى الحافظة تم تصدير %1$d القالب(القوالب) تم استيراد %1$d قالب(قوالب) %1$d مهام التنزيل أضيف مؤخراً الاستيراد من الحافظة هل تريد إزالة%1$d عنصراً (عناصر) من سجل التنزيل للأبد؟ %1$d ملف(ات) فيديو، %2$d ملف(ات) صوت إزالة أو وضع علامات على الأقسام في مقاطع الفيديو باستخدام SponsorBlock API حدد فئات الـSponsorBlock المراد إزالتها او تعليمها في ملف الفيديو تصنيفات الـSponsorBlock انت على أحدث إصدار فشل التحديث إلى أحدث إصدار تحديث البحث عن تحديثات تحقق تلقائياً من أحدث إصدار متوفر على GitHub استخدم aria2c كمدير تنزيل خارجي استخدم ملفات تعريف الارتباط بتنسيق Netscape للتنزيلات امسح الملفات المؤقتة تم حذف %1$d ملف(ات) مؤقت(ة) يمكن استخدام الملفات المؤقتة لاستئناف التنزيلات الملغاة. هل أنت متأكد من حذف كل هذه الملفات؟ \nيمكنك الوصول إلى تلك الملفات في %1$s حذف جميع الملفات المؤقتة من المجلد المؤقت وضع التحديد المتعدد وضع الخصوصية إيقاف سجل التنزيلات لون ديناميكي قم بتطبيق الألوان من الخلفيات على سمة التطبيق هذا الملف لم يعد متوفرا السماح بتنزيل الوسائط عند الاتصال بشبكات محدودة التنزيل باستخدام الشبكة الخلوية التنزيل باستخدام الشبكة الخلوية معطل وفقًا لإعداداتك شبكة حدود المعدل أقصى معدل تحديد الحد الأقصى لسرعة التنزيل مظهر داكن عالي التباين إدخال غير صالح أدنى جودة حدد مقاطع الفيديو لتنزيلها من قائمة التشغيل \"%1$s\" اختيار الكل %1$d محدد غير متوفر صيغة الملف، دقة الفيديو، التَرْجمات إصدار Yt-dlp، الإشعار، قائمة التشغيل حدود المعدل، مدير التنزيلات، ملفات تعريف الارتباط تعطيل المعاينة عدم عرض الصور المصغرة أثناء التنزيل الخصوصية استخدام أمر مخصص مجلد خاص احفظ التحميلات في مجلد خفي قص الغلاف قص الصورة المدمجة على شكل مربع موصى به اختيار التنسيق حدد التنسيق المراد تنزيله قبل بدء العملية فيديو (بدون صوت) إنشاء ملفات تعريف ارتباط جديدة استخدام ملفات تعريف الارتباط هل تريد حذف ملفات تعريف الارتباط الخاصة بـ \"%1$s\" ؟ يرجي العلم بأن ملفات تعريف الارتباط الخاصة بهذا الموقع لن تمسح. قد لا تتوفر بعض الخيارات عند استخدام الأوامر المخصصة كيف يعمل؟ قناة Telegram يتطلب التنزيل من بعض المواقع معلومات مصادقة الحساب. انقر فوق \"إنشاء ملفات تعريف ارتباط جديدة\" ، وأدخل عنوان URL لموقع الويب ، ثم قم بتسجيل الدخول باستخدام حسابك في صفحة المتصفح ، وسيقوم التطبيق بإنشائه لك. مساحة Matrix كوكيز تقدم معظم منصات الفيديو، الصوت والفيديو بشكل منفصل ، يمكنك تحديد ودمج صيغة صوت فقط بصيغة فيديو في فيديو واحد. سيقوم هذا الخيار بتحميل الترجمة التلقائية ان توفرت من المصدر تنزيل الترجمات تعديل الاختصارات مجلد ذاكرة SD الترجمة التلقائية تنزيل سريع نص نموذجي لعنوان الفيديو لغات الترجمة اللغات، دمج العناوين الفرعية، والترجمة التلقائية نسخ السجل إزالة إضافة اختصارات المهام الجارية إظهار السِجل السِجل نص نموذجي لمنشئ الفيديو الترجمة قم بتعديل الاختصارات المخصصة التي يمكن استخدامها لإنشاء قوالب الأوامر. قد يتم توقيت الترجمة بشكل غير صحيح عند إزالة مقاطع SponsorBlock. لتضمين الترجمات، سيتم إعادة صاغية مقاطع الفيديو بصيغة mkv. يمكنك استخدام مشغل VLC أو تطبيقات متوافقة أخرى لمشاهدة مقاطع الفيديو مع الترجمة المضمنة. %.2f م.ب %.2f ج.ب شارك المستقرة المعاينة قم بتثبيت الإصدارات التجريبية لمعاينة الميزات والتغييرات الجديدة. \n \nقد تواجه بعض عدم الاستقرار في هذه النسخ، لذا يرجى عدم التردد في تقديم ملاحظاتك إذا واجهت أي مشاكل لمساعدتنا على تحسين التطبيق للمستقبل. قناة التحديث التحديث التلقائي تفعيل التحديث التلقائي تجاهل استخدام قص الفيديو البداية النهاية ثانية حذف جميع ملفات تعريف الارتباط صيغة الصوت المفضلة غير محدود جودة الصوت أدنى معدل بت حدد معدل بت الصوت في حال تواجد العديد من الدقات فرز الصيغ فرز الصيغ باستخدام الخيار S- من yt-dlp دقيقة استيراد العنوان إعادة التسمية هل تريد حذف جميع ملفات تعريف الارتباط المحفوظة في التطبيق؟ تخزين الملفات المؤقتة في الدليل الداخلي الراعي ادعم هذا التطبيق من خلال الرعاية على GitHub Seal سيبقى دائمًا مجانيًا ومفتوح المصدر للجميع. إذا أعجبك، يُرجى التفكير في دعمي كراعياً على GitHub! التعليقات الرعاة صيغة الصوت لا توجد وسائط منزلة تجريبية تفعيل الميزة التجريبية؟ سيتم تفويض التنزيلات باستخدام هذه الميزة إلى FFmpeg لتنزيل أقسام محددة من الفيديو، لا تزال هذه الميزة تجريبية ولن يكون القطع دقيقًا تمامًا، بالإضافة إلى ان هذه الميزة لا تدعم جميع التنسيقات وقد تواجه سرعات تنزيل أبطأ. اصنع مقاطع فيديو في صفحة اختيار التنسيق التحديث التلقائي غير متاح لنسخة%1$s. إذا لم يكن لديك %1$s مثبت على جهازك، أو ترغب في معاينة الميزات الجديدة القادمة في Seal، يرجى التفكير في%2$s. التحويل إلى إصدارات GitHub حَسَنًا فهمت الميزة غير متوفرة لا توجد مهام أوامر مخصصة رسالة من المطور شكرًا جزيلا! تحويل الترجمة عفواً! حدث خطأ ما النسخ والخروج تحويل الترجمات إلى صيغة أخرى تقسيم الفيديو سيتم تقسيم الفيديو إلى %1$d فصلاً تنزيل مقاطع الفيديو من الرابط توسيع مهمة تحميل جديدة بدأ تعديل \"%1$s\" قم بتحديث yt-dlp بروكسي تعطيل قديم جودة هل ترغب في تفعيل الإشعارات؟ استخدم بروكسي لاتصالات الإنترنت التطبيق يحتاج إلى إذنك لنشر إشعارات حول حالة التنزيل والتقدم. اضغط لفتح صفحة الويب لتوليد ملفات تعريف الارتباط الجديدة: محدد المجلدات غير مفعل حدد مسار التحميل عند استخدام الأوامر المخصصة تفضيل تنسيقات MP4 (H.264) للمشاركة مع تطبيقات أخرى تفضيل تنسيقات AV1، VP9 أو H.265 للمشاهدة في تطبيقات متوافقة نوع التحميل أقرأ المزيد غير معروف تفضيل التنسيقات هل ترغب في إزالة %1$s من قوالب الأوامر بشكل دائم؟ اضغط لتحديد مجلد مجلد الأوامر المخصصة مخصص تلقائي أوامر تصدير إلى ملف Header وكيل العميل %d عنصر %d عنصر عنصران %d عناصر %d عنصرًا %d عنصر yt-dlp هي أداة سطر أوامر قوية لتنزيل مقاطع الفيديو. يجعل Seal استخدام yt-dlp أسهل من خلال توفير واجهة مستخدم بديهية، وإعدادات مسبقة للأوامر الشائعة، وميزات إضافية أخرى. \n \nبالنسبة للاستخدام المتقدم لـ yt-dlp، يسمح لك Seal بإنشاء قوالب أوامر مخصصة وحفظها وتنفيذها مباشرة، تمامًا كما هو الحال في الطرفية. \n \nعند استخدام الأوامر المخصصة، ستتم تعطيل معظم خيارات واجهة المستخدم والميزات. هل تريد مسح أرشيف التحميل؟ هل تريد إزالة %1$s من ملف الأرشيف نهائياً؟ إدماج البيانات الوصفية إعدادات مسبقة ضروري قالب الإخراج حدد القالب لأسماء الملفات الناتجة سجل معرفات الفيديو التي تم تحميلها في أرشيف لتجنب التحميل المكرر أدمج البيانات الوصفية والصورة المصغرة للفيديو في ملف الصوت أرشيف التحميل حفظ استخدم فرز التنسيق حدد أسماء الملفات بأحرف محددة لضمان التوافق عرض كل %1$d العناصر تقييد أسماء الملفات تعديل الملف سيتم حفظ التنزيلات الخاصة بك على النحو التالي: موقع إلكتروني عنوان قائمة التشغيل فرض IPv4 إعدادات النظام قم بإجراء كافة الاتصالات عبر IPv4 السماح مرة واحدة السماح دائماً عدم السماح هل تسمح بالتنزيل باستخدام بيانات الهاتف ؟ دمج بث صوتي متعدد السماح بدمج بث صوتي متعدد في ملف واحد حفظ ملفات الترجمة تم تحميل الفيديو. إذا لم يكن هذا هو السلوك المتوقع، يرجى التحقق من أرشيف التحميل الخاص بك. حفظ احتياطي كامل نوع الحفظ الاحتياطي ملف استرداد سجل التنزيلات؟ استخراج%1$sمن سجل التنزيلات. التنزيلات والتفضيلات لن يتم حفظهم احتياطي. استخراج سجل التنزيلات؟ الترجمة التلقائية الترجمة الآلية لجميع اللغات ستكون متاحة للتنزيل. قد تكون هذه الترجمات غير دقيقة ويصعب فهمها. لغات الترجمة الفرعية للتنزيل التلقائي بفاصلة بين كل منها. حفظ للتحميل القادم المظهر والشعور الواجهة والتفاعل تحديد الاختيار السابق لا شيء إعادة الظبط البحث في الترجمات لا شكرا استخراج استرداد استخراج الي الحافظة استرداد من لا يمكن استرداد التنزيلات. يجب عليك تحميلهم يدوي سجل التنزيلات اللغات التالية سوف تتضاف لتفضيلاتك في التنزيلات القادمة: تحديث لغة الترجمة؟ استرداد %1$sالي سجل التنزيلات إعادة التحميل البحث في التنزيلات البحث تحويل صيغة الفيديوهات إلى MKV لتوافق أفضل حاوية فيديو Remux "%1$d ملفات تعربف إرتباط مخزنة لعدد %2$d موقع" كل يوم كل اسبوع كل شهر كل اللغات قائمة التشغيل متابعة الإعداد المسبق تفضيل %1$s تنزيل أفضل تنسيق متاح فيديو %d فيديو فيديوهان %d فيديوهات %d فيديو %d فيديو صوت ­%d صوت صوتان %d أصوات %d صوت %d صوت تمت إضافة المهمة إلى قائمة الانتظار اختر من بين التنسيقات والترجمات وقم بتخصيصها بشكل أكبر تنزيل تلقائي باستخدام تفضيلات التنسيق الخاصة بك تعديل الإعداد المسبق ستجد التنزيلات الخاصة بك هنا اضغط على زر التنزيل أو قم بمشاركة رابط الفيديو لهذا التطبيق لبدء التنزيل تم التنزيل الجميع اختر من %1$d رابطًا قائمة انتظار التنزيل إظهار درج التنقل استمرار حذف إصلاح الأخطاء الشائعة والتحقق من المشاكل المعروفة استكشاف الأخطاء وإصلاحها متتبع المشكلة هل واجهت خطًأ؟ قبل الإبلاغ عن مشكلة جديدة، يرجى البحث في أداة تتبع المشاكل لدينا. لقد عولجت العديد من المشاكل الشائعة وتوثيقها بالفعل هناك. معلومات الوسائط ================================================ FILE: app/src/main/res/values-ar-rSA/strings.xml ================================================ طلع الصوت بس احفظ صورة المقطع الاعدادات نزله معليش المفروض الرابط مو فاضي نزّل الصوت بعدين احفظه, مكان المقطع استخدم آخر نسخة من yt-dlp ترنا ندور عن معلومات الفيديو اذا ما عليك امر. ماش رفضونا. خلص التحميل. تامر على شي ثاني حاضرين معليش ما قدرت انزل. تحميل \"%1$s\" ما قدرت اجيب معلومات الفيديو عام لغة العرض ثبتنا لغة عرضك الصق الرابط معليش ما لقيا الرابط في الحافظة انسخ مرة ثانية لاهنت نسخة Yt-dlp حدث yt-dlp تبي تحذف؟ احذف \"%1$s\" من سجل التصفح افضلك؟ أكد الغينا التحميلات عام , الصيغة, أوامر خاصة احفظ صورة المقطع في مكان ثاني خلاص شف قاعدين ننزله لك لا تشغلنا. اضغط هنا عشان انزلك اخر نسخة لـ yt-dlp الصوت ملفات الفيديو مقدرت انزّل اخر نسخة من yt-dlp. لاهنت تقدر تشيك على اتصال الانترنت لا يكون مفصول بس. تم نسخ الرابط إلى الحافظة افتح الرابط حذف الملف حول الإصدار، التعليقات، التحديث التلقائي تراجع الإصدار ابحث عن سجلات التغييرات والإصدارات الجديدة أحدث إصدار تحقق من مستودع GitHub وملف README فيديو محدد الاعتمادات أمر مخصص تشغيل أمر yt-dlp باستخدام قالب مخصص قالب الأوامر تعديل الاعتمادات والبرمجيات الحرة ================================================ FILE: app/src/main/res/values-az/strings.xml ================================================ Video qovluq Səs kimi saxla Miniatürü saxla Tənzimləmələr Ümumi, format, fərdi əmr Yüklə Bağlantı boş ola bilməz Video əvəzində, səs yüklə və saxla Video miniatürü fayl kimi saxla yt-dlp-nin ən son versiyası işlədilir Ən son yt-dlp versiyasın quraşdırmaq alınmadı. Zəhmət olmasa, İnternetə qoşduğunuzdan əmin olun. Video məlumatı alınır… İcazə rədd edildi Yükləmə bitdi \"%1$s\" faylın yüklə Video məlumatı almaq mümkün olmadı Ümumi Göstərilmə dili Göstərilmə dilin təyin et Mövcud yükləmə tapşırığı artıq idarə edilir URL-ni Yapışdır Kəsik lövhədəki URL uyuşmadı Ən son yt-dlp versiyasın quraşdırmaq üçün kliklə Silinsin\? \"%1$s\" yükləmə tarixçənizdən həmişəlik silinsin\? Təsdiqlə Ləğv et Yüklənənlər Bağlantı lövhəyə köçürüldü Bağlantını aç Təmizlə Haqqında Geri Versiya Dəyişiklik jurnalları və yeni versiyaları axtar Ən son buraxılış Video Yoxlanılıb Köməkçilər Kreditlər və pulsuz proqram təminatı Fərdi əmr yt-dlp əmrin, fərdi şablonla idarə et Əmr şablonu Redaktə et Əmri icra etməyə başla Qabaqcıl Ətraflı buraxılış Yükləmə zamanı ətraflı məlumatları çap et Göstərilmə Qaranlıq tema, dinamikalı rəng, dillər Qaranlıq tema Sistem Bağla Ləğv et Yükləməzdən əvvəl konfiqurasiya et Bu yükləməni tənzimlə Xəta məlumatı lövhəyə köçürüldü Miniatür Yapışdır Yt-dlp istifadə istinadları Buraxılış yolu və URL tətbiq tərəfindən əlavə ediləcək. Səs formatına döndər Döndərilməmiş %1$s-a döndər Format Səs faylların yenidən kodlaşdırmaq səs keyfiyyətində itkiyə və fayl həcmində artıma səbəb olacaq. Video keyfiyyət Ən yaxşı keyfiyyət Çoxsaylı olduqda video keyfiyyətinə məhdudiyyət qoy Müəyyənləşdirilməyib (standart) Üstünlük verilən video formatı Çoxsaylı təmin ediləndə üstünlük verilən format Video formatı Yüklə Bağla İstifadəçi bələdçisi Tənzimləmələri aç Tənzimləmələrin nizamladıqdan sonra \"Yüklə\"-ni kliklə. Videolar və səs fayllar daxil olmaqla tətbiqdaxili yükləmələri yoxla və idarə et. Oynatma siyahısın yüklə Oynatma siyahısından çoxlu video yüklə Standart Yüklə Yüklənilmiş fayllar və irəliləyiş bildirilir Video bağlantısı Yükləmə bitdi. Açmaq üçün toxun. Fərdi əmrlər icra edilir… Zəhmət olmasa, arxa planda yükləmək üçün bu tətbiqin batareya istifadəsin sistem seçimlərində \"Qeyri-məhdud\" kimi təyin edin. Çox yönlü yükləmə Seçimlər Əlavə tənzimləmələr Paylaşılan məzmundan URL-i uyuşdurmaq mümkünsüzdür Paylaşılan məzmundan video linki oxunur… Daha çox fəaliyyət göstər Yükləmə bildirişi Oynatma siyahısı seçmə Hamısın seç Faylı yükləmək mümkün olmadı Yt-dlp versiyası Səs Faylı sil Versiya, əks əlaqə, avtomatik yeniləmə GitHub anbarı və README-ni yoxla Yükləməzdən əvvəl seçimləri konfiqurasiya et Döndər Təkrar göstərmə Lövhənizdən video əlaqəsin əldə etmək üçün \"Yapışdır\"-a klikləyin. Yükləmə tənzimləmələrinə baxın və istifadədən əvvəl yt-dlp\'nin ən son versiyasına sahib olduğunuzdan əmin olun. M3U8/MPD videoların daha çox hissələrin yanaşı yüklə %d bölüm DASH/HLS yerli videosun eyni vaxtda yükləmək üçün istifadə olunacaq. Yüklənilmiş fayllar və irəliləyiş bildirilir Oynatma siyahısı məlumatı alınır… Oynatma siyahısından (%1$d-dən %2$d-a) yükləmək üçün videoların həddin müəyyənləşdir \"%3$s\". Başlanğıc Son Etibarsız göstərici həddi Səs qovluğu Yükləmə kataloqu Video və səs faylların haradasa saxlamaq üçün seç Alt kataloqda saxla Saxlama icazəsi problemi Seal yükləyir… Naməlum xəta Tərcümə et Hosted Weblate-də bu tətbiqi tərcümə etməyə kömək et Download/ və Documents/ xaricindəki kataloqlar dəstəklənmir Ön şəkilçi Yükləmə davam edir … Titrləri yerləşdir Yeni şablon Etiket Təmizlənsin\? \"%1$s\" əmr şablonlarından həmişəlik silinsin? Şablon seçimi Əmr şablonların redaktə et və idarə et Yükləmə tapşırığı ləğv edildi GitHub problemi Məlumat lövhəyə köçürüldü Bitdi Yüklənilir Ləğv edildi Məlumat alınır Faylı aç Yenidən başlat Bağlantını köçür Məlumatı köçür Video görüntü imkanı Video fayl həcmi Lövhəyə ixrac et Lövhədən idxal et %1$d yükləmə tapşırığı Yenicə Əlavə Edilib %1$d element yükləmə tarixçənizdən həmişəlik silinsin\? SponsorBlock API ilə videolarda bölümləri təmizlə yaxud işarələ SponsorBlock kateqoriyaları Yeniləmələr üçün yoxla GitHub-dakı ən son versiyanı avtomatik yoxla Cari versiya güncəldir Ən son versiyaya yeniləmək alınmadı Yenilə Xarici yükləyici kimi aria2c istifadə et %1$d müvəqqəti fayl silindi Gizli Dinamikalı rəng Rəngləri divar kağızlarından tətbiq mövzusuna tətbiq et Mobil şəbəkə işlədərək yüklə Mobil şəbəkə ilə yükləmə tənzimləmələrinizə uyğun olaraq qeyri-aktivdir Bu fayl artıq mövcud deyil Şəbəkə Sürət həddi Maksimum yükləmə sürətin məhdudlaşdır Maksimum sürət Yüksək ziddiyyət, qaranlıq mövzu Ən aşağı keyfiyyət Əlçatmaz Yt-dlp versiyası, bildiriş, oynatma siyahısı Sürət həddi, yükləyici, məlumat bazası Önizləməni qeyri-aktiv et Yükləmə əsnasında miniatürlər göstərilmir Məxfilik Şəxsi kataloq Yükləmələri gizli kataloqda saxla Şəkli kəs Yerləşdirilən şəkli kvadrat şəklində kəs %1$d seçildi Batareya konfiqurasiyası Arxa planda yükləmə üçün, bu tətbiqin batareya optimallaşdırılmasın istəmə Oynatma siyahısı yüklənilir (%1$d/%2$d)… Faylları uyğun sahələr kimi adlandırılan qovluqlarda saxla Səhv məlumatı və ya xüsusiyyət sorğusu üçün problem təqdim et Varsa, videolara titrləri yerləşdir Növbəyə salındı Xəta %1$d video, %2$d səs faylı %1$d şablon ixrac edildi %1$d şablon idxal edildi Yükləmələr üçün Netscape formatlı məlumat bazası istifadə et Video faylda silmək və ya işarələmək üçün SponsorBlock kateqoriyaları müəyyənləşdir Müvəqqəti qovluqdan bütün müvəqqəti faylları silin Çoxseçimli rejim Müvəqqəti faylları təmizlə Müvəqqəti fayllar imtina edilmiş yükləmələri davam etdirmək üçün istifadə edilə bilər. Bu faylların hamısın siləcəyinizə əminsiniz? \n \nBu fayllara %1$s ilə giriş edə bilərsiniz Yükləmə tarixçəsin qeyri-aktiv et Ölçülmüş şəbəkələrə qoşduqda media yüklənilməsinə icazə ver Fərdi əmr istifadə et Etibarsız giriş Fayl formatı, video keyfiyyəti, titrlər \"%1$s\" pleylistindən yükləmək üçün videoları seç Video (səs yoxdur) Format seçimi Məsləhət görülən Yükləmə başlamazdan əvvəl yükləmək üçün format seç Yeni məlumat bazası yarat Məlumat bazası istifadə et Bu giriş \"%1$s\" üçün silinsin? Nəzərə alın ki, bu sayt üçün saxlanılan məlumatlar silinməyəcək. Fərdi əmr istifadə edərkən bəzi seçimlər əlçatmazdır Bu necə işləyir\? Bəzi saytlardan yükləmə zamanı hesab təsdiqləmə məlumatı tələb olunur. \"Yeni məlumat bazası yarada\" kliklə, vebsayt URL-ni daxil et və sonra brauzer səhifəsində hesabınızla daxil olun, tətbiq sizin üçün bunu yaradacaq. Telegram kanalı Məlumat bazası Matrix məkanı Əksər video yayım platformaları səs və videonu ayrı çatdırır, siz bir video üçün yalnız video formatı ilə yalnız səs formatın seçə və birləşdirə bilərsiniz. Avtomatik titrlər Video başlıq mətn nümunəsi Titr Titrləri yüklə Titr dilləri Dillər, yerləşdirilən titrlər, avtomatik titrlər Jurnalı köçür Təmizlə Qısayolları redaktə et Əlavə et Jurnalı göstər Jurnal SD kart qovluğu Avtomatik yaradılan titrləri yüklə Sürətli yükləmə Video yaradıcı mətn nümunəsi Qısayollar Əmr şablonların tərtib etmək üçün istifadə edilən fərdi qısayolları redaktə edin. İdarə edilən tapşırıqlar SponsorBlock bölümləri silinən zaman titrlər vaxtı səhv ola bilər. Titrləri yerləşdirmək üçün videolar mkv konteynerinə yenidən daxil ediləcək. Titrlərlə videolara baxmaq üçün VLC Media Player və ya digər, uyğun tətbiqləri istifadə edə bilərsiniz. %.2f GB %.2f MB Paylaş Yeniləmə kanalı Avtomatik yeniləməni aktivləşdir Stabil İlkin baxış Yeni xüsusiyyətlər və dəyişikliklərə ilkin baxış üçün ilkin buraxılış quruluşları quraşdır. \n \nBu versiyalarda az-çox qeyri-stabillik olacaq, beləliklə gələcəkdə tətbiqi təkmilləşdirmək,bizə kömək etmək üçün hər hansı problemlərlə qarşılaşsanız, bizə rəy bildirməyə tərəddüd etməyin. Avtomatik yenilənmə Tətbiq et Videonu kəs Son Rədd et Başlanğıc Üstünlük verilən səs formatı Yt-dlp -S seçimi ilə çeşidlənən formatlar Tətbiqdə saxlanılan bütün məlumatlar həmişəlik silinsin\? Qeyri-məhdud Ən aşağı bit sürəti Səs keyfiyyəti Çoxsaylı keyfiyyətlər mövcud olduqda səs bit sürətin məhdudlaşdır Format çeşidlənməsi İdxal Başlıq Yenidən adlandır saniyə dəqiqə Bütün məlumat bazaların sil Müvəqqəti faylları daxili kataloqda saxla Himayədar Əks əlaqə Himayədarlar GitHub-da himayədarlıq edərək bu tətbiqi dəstəklə Seal həmişə, hamı üçün pulsuz və açıq mənbə olacaqdır. Əgər bəyənirsinizsə, mənə GitHub-da himayədarlıq etməyi düşünün! Səs formatı Yüklənilən media yoxdur Sınaq Təcrübi xüsusiyyət aktivləşdirilsin\? Format seçim səhifəsində video kliplər yarat Bu xüsusiyyəti işlədən yükləmələr, videonun seçilən bölmələrin yükləmək üçün FFmpeg-yə həvalə olunacaq, bu xüsusiyyət hələ təcrübidir və kəsmə tamamilə dəqiq olmayacaq, bütün formatlar bu xüsusiyyəti dəstəkləmir və daha yavaş yükləmə sürəti görə bilərsiniz. Avtomatik yeniləmə %1$s quruluşlar üçün əlçatan deyil. Cihazınızda quraşdırılmış %1$s yoxdursa və ya Seal-da növbəti yeni xüsusiyyətləri nəzərdən keçirmək istəyirsinizsə, zəhmət olmasa %2$s-ı nəzərdən keçirin. GitHub quruluşlarına keçid Oldu Anladım Xüsusiyyət mövcud deyil Fərdi əmr tapşırıqları yoxdur Tərtibatçıdan mesaj Çox sağ olun! Videoları URL-dən yüklə Titrləri çevir Titrləri başqa formata çevir Videonu böl Video %1$d bölməyə bölünəcək Vay! Nəsə səhv oldu Köçür və çıx Genişləndir Yeni yükləmə tapşırığı Başlat \"%1$s\" redaktə et %1$s əmr şablonlarından həmişəlik təmizlənsin\? %d element %d element Yeni bazalar yaratmaq üçün veb səhifə açmağa toxun: Yt-dlp-ni yenilə Proksi Köhnə İnternet bağlantıları üçün proksi işlət Bildirişlər aktivləşdirilsin? Tətbiqin yükləmə vəziyyəti və irəliləyiş barədə bildirişlər göndərməsi üçün icazənizə ehtiyacı var. Keyfiyyət İstifadəçi-Agent başlığı Qeyri-aktiv et Kataloq yaratmaq üçün toxun Fərdi əmr kataloqu Qeyri-aktivdir Qovluq seçici Fərdi əmrlər istifadə edərkən çıxış qovluğun təyin et Digər tətbiqlərdə paylaşmaq üçün MP4(H.264) formatlarına üstünlük ver Uyğun tətbiqlərdə baxmaq üçün AV1, VP9 və ya H.265 formatlarına üstünlük verin Yükləmə növü Fərdi Avtomatik Əmrlər Format üstünlüyü Daha çox öyrən Naməlum Fayla ixrac et yt-dlp videoları yükləmək üçün güclü əmr xətti alətidir. Seal intuitiv GUI, ictimai əmrlər üçün qabaqcıl tənzimləmələr və digər əlavə funksiyalar təmin etməklə yt-dlp istifadə etməsini asanlaşdırır. \n \nSeal sizə yt-dlp-nin qabaqcıl istifadəsi üçün terminalda olduğu kimi birbaşa fərdi əmr şablonların yaratmağa, saxlamağa və icra etməyə icazə verir. \n \nFərdi əmrlərdən istifadə edərkən, GUI seçimlərinin və xüsusiyyətlərinin əksəriyyəti qeyri-aktiv ediləcək. Yükləmə arxivi təmizlənsin? %1$s arxiv faylından həmişəlik silinsin? Yerləşdirilən Əlavə Məlumat İlkin tənzimləmələr Buraxılış şablonu Buraxılış fayl adları üçün şablonu təyin edin Dublikat yükləmələrə əngəl olmaq üçün yüklənilmiş video ID-ləri arxivdə qeyd edin Yükləmə arxivi Saxla Yükləmələriniz aşağıdakı kimi saxlanılacaq: Veb sayt IPv4-ə məcbur et Format çeşidlənməsin istifadə et Uyğunluğu təmin etmək üçün fayl adların xüsusi simvollarla məhdudlaşdır Titr faylların saxla Tələb olunan Bütün %1$d elementi göstər Sistem tənzimləmələri Bütün rabitələri IPv4 vasitəsilə qur Fayl adların məhdudlaşdır Fayla düzəliş edin Səs faylına əlavə məlumat və video miniatürü yerləşdir Oynatma siyahısı başlığı (adı) Bir dəfə icazə ver Mobil şəbəkə ilə yükləməyə icazə verilsin? Çoxlu səs axının birləşdir Çoxlu səs axınların bir faylda birləşdirməyə icazə ver Həmişə icazə ver İcazə vermə Təkrar daxil edilən video konteyner Daha yaxşı uyğunluq üçün videoları MKV konteynerinə təkrar daxil edin Video endirilib. Bu gözlənilən davranış deyilsə, xahiş olunur yükləmə arxivinizi yoxlayın. Nüsxələmə növü Bura ixrac et Fayl Lövhə Yükləmə tarixçəsi idxal edilsin? Yüklənilmiş fayllar idxal edilməyəcək. Onları əl ilə geri yükləməlisiniz Ümumilikdə, %2$d vebsaytdan %1$d məlumat Avtomatik tərcümə edilmiş titrlər Bütün dillər üçün avtomatik tərcümə edilmiş titrlər yükləmələrdə mövcud olacaq. Bu titrlər yanlış və anlaşılması çətin ola bilər. Avtomatik format seçimində yükləmək üçün titrlərin dili, vergüllə ayrılan. Görünüş və hissiyyat Növbəti yükləmə üçün xatırla Əvvəlki seçimi istifadə et Görünmə və qarşılıqlı təsir Heç biri Sıfırla Titrlərdə axtar Xeyr, təşəkkürlər İxrac et İdxal et Tam ehtiyat nüsxələmə Burdan idxal edin Yükləmə tarixçəsi ixrac edilsin? Yükləmə tarixçəsindən %1$s ixrac edilir. Yüklənilən fayllar və seçimlər nüsxələnməyəcək. Yükləmə Tarixçəsi Aşağıdakı dillər gələcək yükləmələr üçün seçiminizə əlavə olunacaq: Titr dilləri yenilənsin? Hər gün Hər həftə Hər ay Yükləmə tarixçəsinə %1$s idxal edildi Yenidən yüklə Yükləmələrdə axtarış Axtar Bütün dillər Oynatma siyahısı İlkin tənzimləmə %1$s-a üstünlük ver Format seçimlərinizi istifadə edərək avtomatik yükləyin İlkin seçimə düzəliş et Mövcud ən yaxşı formatı yüklə %d səs %d səs Tapşırıq növbəyə əlavə edildi Davam et Formatlardan, titrlərdən seç və daha çox fərdiləşdir %d video %d video Endirmələri burada tapa bilərsiniz Yükləməyə başlamaq üçün yüklə düyməsinə toxun və ya bu tətbiq üçün video linkin paylaş Endirildi Hamısı ­%1$d linkdən seçin ­Yükləmə növbəsi Hərəkət qutusun göstər Davam et Sil Media məlumatı Xəta göründü? Yeni problemi bildirməzdən əvvəl, lütfən problem izləyicimizi araşdırın. Bir çox ümumi problemlər artıq var və orada sənədləşdirilib. Problemlərin həlli Problem izləyicisi Düzəlmiş ümumi səhvlər və məlum problemləri yoxla Saxlanan bağlantılar Yeni link əlavə et %1$s-a əlavə et ================================================ FILE: app/src/main/res/values-be/strings.xml ================================================ Асноўныя налады, фармат, карыстальніцкая каманда Папка для відэа Захаваць як аўдыя Захаваць мініяцюру Спампаваць Спампаваць і захаваць аўдыя замест відэа Захаваць мініяцюру відэа як файл Выкарыстоўваецца найноўшая версія yt-dlp Атрыманне інфармацыі аб відэа… У доступе адмоўлена Спампоўка скончана Не атрымалася спампаваць файл Спампаваць \"%1$s\" Не ўдалося атрымаць інфармацыю пра відэа Асноўныя Мова праграмы Усталюйце мову адлюстравання Існуючая задача спампоўкі ўжо запушчана Устаўлены URL-адрас Не атрымалася супаставіць URL у буферы абмену Yt-dlp версія Націсніце, каб усталяваць апошнюю версію yt-dlp Выдаліць\? Назаўсёды выдаліць \"%1$s\" з гісторыі спамповак\? Пацвердзіць Адмяніць Спампоўкі Аўдыё Адкрыць спасылку Выдаліць Выдаліць файл Аб праграме Версія, водгукі і абнаўленні Назад Налады Спасылка не можа быць пустой Немагчыма ўсталяваць апошнюю версію yt-dlp. Калі ласка, упэўніцеся што вы падключаны да Інтэрнэту. Спасылка скапіявана ў буфер абмену Пачаць выкананне каманды Рэдагаваць Шаблон каманды Версія Азнаёмцеся са спісам змен і новых версій Апошні рэліз Праверце рэпазітар GitHub і README Відэа Праверана Падзякі Падзякі і бясплатнае праграмнае забеспячэнне Карыстальніцкая каманда Запусціце yt-dlp з карыстальніцкім шаблонам Пашыраныя налады Падрабязная справаздача Націсніце «Уставіць», каб атрымаць спасылку на відэа з буфера абмену. Затым націсніце \"Спампаваць\", наладзіўшы яго параметры. Правярайце і кіруйце загрузкамі ў праграме, уключаючы відэа і аўдыяфайлы. Спампаваць плэйліст Апавяшчаць аб запампаваных файлах і прагрэсе Спампоўка скончана. Націсніце, каб адкрыць. Спампаваць Спасылка на відэа Выводзіць падрабязныя паведамленні пры загрузцы (%) Знешні выгляд Цёмная тэма, дынамічны колер, мовы Цёмная тэма Як у сістэме Уключана Выключана Адмяніць Наладзьце перад загрузкай Параметры загрузкі Наладзьце параметры перад загрузкай Справаздача аб памылцы скапіравана ў буфер абмену Мініяцюра Уставіць Рэкамендацыі па выкарыстанню Yt-dlp Шлях вываду і URL будуць дададзены праграмай. Не канвертаваць Пераўтварыць у %1$s Фармат Паўторнае кадзіраванне аўдыяфайлаў прывядзе да страты якасці гуку і павелічэння памеру файла. Якасць відэа Найлепшая якасць Абмежаваць якасць відэа, калі прысутнічае некалькі Не ўказана (па змаўчанні) Пераважны фармат відэа Выбраны фармат, калі прадастаўлена некалькі Фармат відэа Канвертаваць Спампаваць Зачыніць Не паказваць зноў Інструкцыя для карыстальніка Адчыніць налады Зірніце на налады загрузкі і пераканайцеся, што ў вас апошняя версія yt-dlp перад яе выкарыстаннем. Спампаваць некалькі відэа з плэйліста Па змаўчанні Выкананне карыстальніцкай каманды… Каб спампоўваць у фонавым рэжыме, усталюйце ў сістэмных наладах \"Неабмежаваны\" расход батарэі для гэтай праграмы. Шматструменная загрузка Спампоўвайце больш частак відэа M3U8/MPD паралельна Канвертаваць фармат аўдыё Захаваць у падкаталог %d патокаў будзе выкарыстана для адначасовай спампоўкі відэа DASH/HLS. Налады Дадатковыя налады Немагчыма знайсці URL з абагуленага змесціва Чытанне спасылкі на відэа з абагуленага змесціва… Паказаць больш дзеянняў Апавяшчэнне аб спампоўцы Апавяшчаць аб запампаваных файлах і прагрэсе Атрыманне інфармацыі аб плэйлісце… Выбар плэйліста Укажыце дыяпазон відэа для спампоўкі са спісу прайгравання «%3$s» (ад %1$d да %2$d). Пачаць Закончыць Няправільны дыяпазон індэкса Спампоўка плэйліста (%1$d/%2$d)… Папка з аўдыё Каталог загрузкі Выберыце, дзе захоўваць відэа і аўдыяфайлы У чарзе Завершана Ідзе загрузка Адменена Атрыманне інфармацыі Адкрыць файл Перазапусціць Памылка Скапіяваць спасылку Захоўвайце файлы ў папках з адпаведнымі назвамі Каталог не падтрымліваецца Ігнаруйце аптымізацыю акумулятара, каб спампоўваць у фоне Seal спампоўвае… Невядомая памылка Пераклад Дапамажыце з перакладам на Hosted Weblate Шаблон шляху Убудаваць субтытры Загаловак Выдаліць\? Назаўсёды выдаліць \"%1$s\" з шаблонаў каманд\? Рэдагаванне і кіраванне шаблонамі каманд Ідзе спампоўка… Спампоўка адменена Паведаміць пра праблему (GitHub) Адправіць праблему для справаздачы аб памылцы або запыту функцыі Спасылка скапіявана ў буфер абмену Дырэкторыі па-за Download/ і Documents/ не падтрымліваюцца Канфігурацыя батарэі Убудоўваць у відэафайл прэрэндэрныя субтытры, калі яны даступныя Новы шаблон Выбар шаблону Высокакантрасная цёмная тэма Няправільны ўвод Самая нізкая якасць Капіяваць справаздачу Якасць відэа Памер відэафайла Экспарт у буфер абмену Імпарт з буфера абмену Экспартаваны шаблон(ы) %1$d Імпартаваны шаблон(ы) %1$d %1$d Загрузачных задач Нядаўна дададзеныя %1$d відэа(я), %2$d аўдыяфайл(ы) Выдаліць %1$d элемент(ы) з вашай гісторыі спамповак назаўжды\? Выдаляйце або пазначайце сегменты ў відэа з дапамогай SponsorBlock API Укажыце катэгорыі SponsorBlock для выдалення або пазначэння ў відэафайле Катэгорыі SponsorBlock Праверце наяўнасць абнаўленняў Аўтаматычна правяраць апошнюю версію на GitHub Бягучая версія актуальная Ачысціць часовыя файлы Выдаліць усе часовыя файлы з часовага каталога Выдалена %1$d часовых файл(ы) Рэжым множнага выбару Інкогніта Адключыць гісторыю загрузак Часовыя файлы можна выкарыстоўваць для аднаўлення адмененых загрузак. Вы ўпэўненыя, што жадаеце выдаліць усе гэтыя файлы\? \n \nВы можаце атрымаць доступ да гэтых файлаў у %1$s Дынамічныя колеры Ужываць колеры шпалер да тэмы праграмы Загружаць па сотавай сувязі Дазволіць спампоўку мультымедыя пры падключэнні да сетак з лімітам трафіку Спампоўка праз сотавую сетку адключана ў адпаведнасці з вашымі наладамі Гэты файл больш не даступны Сетка Ліміт хуткасці Абмежаваць максімальную хуткасць загрузкі Максімальная хуткасць Выкарыстоўвайце aria2c у якасці вонкавага загрузчыка Для загрузкі выкарыстоўвайце файлы cookie ў фармаце Netscape Не ўдалося абнавіць да апошняй версіі Абнавіць Відэа (без аўдыя) Прапанаваў Перад пачаткам загрузкі абярыце фармат для загрузкі Большасць платформаў струменевага відэа перадаюць аўдыя і відэа асобна, вы можаце выбраць і аб\'яднаць фармат толькі аўдыя з фарматам толькі відэа ў адно відэа. Прыклад аўтара відэа Мовы, убудаваныя субтытры, аўтаматычныя субтытры Выбар фармату Выдаліць гэты запіс для \"%1$s\"? Звярніце ўвагу, што файлы cookie, захаваныя для гэтага сайта, не будуць выдалены. Мовы субтытраў %1$d выбрана Прыватны каталог Недаступны Версія yt-dlp, апавяшчэнне, плэйліст Cookies Адключыць папярэдні прагляд Не адлюстроўваюцца мініяцюры падчас загрузкі Захоўвайце загрузкі ў схаваным каталогу Прыватнасць Telegram канал Матрычная прастора Абмежаванне хуткасці, загрузнік, файлы cookie Выкарыстоўвайце карыстальніцкую каманду Абрэзаць мастацкі твор Абрэзаць убудаваны відарыс у квадрат Фармат файла, якасць відэа, субтытры Выберыце відэа для спампоўкі са плэйліста \"%1$s\" Абраць усё Стварыце новыя файлы cookie Выкарыстоўвайце файлы cookie Некаторыя параметры недаступныя пры выкарыстанні карыстальніцкай каманды Як гэта працуе\? Спампоўка з некаторых сайтаў патрабуе аўтэнтыфікацыі ўліковага запісу. Націсніце «Стварыць новыя файлы cookie», увядзіце URL вэб-сайта, а затым увайдзіце пад сваім уліковым запісам на старонцы браўзера, праграма згенеруе яго для вас. Тэчка SD карты Аўтаматычныя субтытры Спампаваць аўтаматычна створаныя субтытры Хуткая загрузка Прыклад назвы відэа Падзагаловак Спампаваць субтытры Пераважны аўдыяфармат Якасць гуку Сартаванне па фармаце Неабмежаваны другі Перайменаваць хвіліна Фармат аўдыё Няма спампаваных мультымедыя Бэта-версія Уключыць эксперыментальную функцыю\? Зрабіце відэакліпы на старонцы выбару фармату %.2f МБ %.2f ГБ Пры выдаленні сегментаў SponsorBlock субтытры могуць быць няправільнымі. Уключыць аўтаматычнае абнаўленне Падзяліцца Стабільны Папярэдні Усталюйце папярэднія зборкі, каб праглядзець новыя функцыі і змены. \n \nУ гэтых версіях будзе назірацца некаторая нестабільнасць, таму, калі ласка, не саромейцеся дасылаць нам водгукі, калі ў вас узнікнуць праблемы, каб дапамагчы нам палепшыць прыкладанне ў будучыні. Абнавіць канал Аўтаматычнае абнаўленне Журнал Аўтаматычнае абнаўленне недаступна для зборак %1$s. Калі ў вас на прыладзе не ўсталявана %1$s, або вы хочаце папярэдне прагледзець будучыя новыя функцыі ў Seal, звярніце ўвагу на %2$s. пераход на зборкі GitHub ОК Зразумела Функцыя недаступная Адкідаць Канец Пачатак Ужыць Кліп відэа Самы нізкі бітрэйт Ачысціць усе файлы cookie Спампоўкі з выкарыстаннем гэтай функцыі будуць дэлегаваны FFmpeg для загрузкі выбраных раздзелаў відэа, гэтая функцыя ўсё яшчэ эксперыментальная, і выразанне не будзе цалкам дакладным, не ўсе фарматы падтрымліваюць гэту функцыю, і хуткасць спампоўкі можа знізіцца. Скапіраваць журнал Адрэдагуйце карыстальніцкія цэтлікі, якія можна выкарыстоўваць для стварэння шаблонаў каманд. Запуск задач Паказаць журнал Ачысціць Дадаць Цэтлікі Рэдагаваць цэтлікі Каб убудаваць субтытры, відэа будуць рэмуксаваныя ў кантэйнер mkv. Вы можаце выкарыстоўваць VLC Media Player або іншыя сумяшчальныя праграмы для прагляду відэа з убудаванымі субтытрамі. Абмяжуйце бітрэйт аўдыя, калі прысутнічае некалькі якасцей Сартаванне фарматаў з дапамогай опцыі -S у yt-dlp Імпарт Назва Назаўсёды выдаліць усе файлы cookie, якія захоўваюцца ў праграме\? Спонсар Падтрымайце гэтую праграму, спансіруючы яго на GitHub Seal заўсёды будзе бясплатным і адкрытым для ўсіх. Калі вам гэта падабаецца, калі ласка, падумайце аб тым, каб стаць спонсарам мяне на GitHub! Зваротная сувязь Спонсары Захоўвайце часовыя файлы ва ўнутраным каталогу Няма карыстацкіх камандных задач Паведамленне ад распрацоўніка Вялікі дзякуй! Спампаваць відэа з URL-адрасу Канвертаваць субтытры Канвертаваць субтытры ў іншы фармат Капіяваць і выйсці Раздзяліць відэа Відэа будзе падзелена на %1$d раздзелаў Ой! Нешта пайшло не так Разгарнуць Новая задача загрузкі Пачаць Рэдагаваць \"%1$s\" Проксі Падключыцеся праз проксі Націсніце, каб наладзіць каталог Укажыце выходны каталог пры выкарыстанні карыстальніцкіх каманд Выберыце AV1, VP9 або H.265 для прагляду ў сумяшчальных праграмах Свая Аўто Каманды Невядомы Тып загрузкі Налады фармату Даведайцеся больш Націсніце, каб адкрыць вэб-старонку для стварэння новых файлаў cookie: Абнавіць yt-dlp Уключыць апавяшчэнні\? Праграме патрабуецца ваш дазвол, каб публікаваць апавяшчэнні аб стане і прагрэсе загрузкі. Адключыць Устарэлы Якасць Каталог карыстацкіх каманд Выключана Выбар тэчак Выберыце MP4 (H.264) для абмену ў іншых праграмах Выдаліць %1$s з камандных шаблонаў назаўжды\? %d элемент %d элементы %d элементаў %d элементаў Загаловак User-Agent Экспарт у файл yt-dlp - гэта магутны інструмент каманднага радка для спампоўкі відэа. Seal палягчае выкарыстанне yt-dlp, забяспечваючы інтуітыўна зразумелы графічны інтэрфейс, прадусталяваныя налады для агульных каманд і іншыя дадатковыя функцыі. \n \nДля прасунутага выкарыстання yt-dlp Seal дазваляе ствараць, захоўваць і выконваць карыстальніцкія шаблоны каманд непасрэдна, як у тэрмінале. \n \nПры выкарыстанні карыстальніцкіх каманд большасць опцый і функцый графічнага інтэрфейсу будуць адключаны. Прасэты Вывад шаблону Укажыце шаблон для імёнаў выходных файлаў Ачысціць архіў загрузак\? Выдаліць %1$s з архіўнага файла назаўжды\? Запісваць ID загружаных відэа ў архіў, каб пазбегнуць дублікатаў Архіў загрузак Убудаваць метаданыя Абавязковы Устаўце метаданыя і мініяцюру відэа ў аўдыяфайл Паказаць усе элементы (%1$d) Захаваць Выкарыстоўвайце сартаванне па фармаце Абмяжуйць імёны файлаў пэўнымі сімваламі, каб забяспечыць сумяшчальнасць Абмежаваць імёны файлаў Рэдагаваць файл Вашы спампоўкі будуць захаваны як: Вэб-сайт Налады сістэмы Назва плэйліста Прымусова выкарыстоўваць IPv4 Выконваць усе падключэнні праз IPv4 Захаваць файлы субтытраў Дазволіць адзін раз Дазваляць заўсёды Не дазваляць Дазволіць спампоўку праз мабільную перадачу даных? Аб\'яднаць некалькі аўдыёпатокаў Дазволіць аб\'ядноўваць некалькі аўдыяпатокаў у адзін файл Пошук у загрузках Пошук Субтытры з аўтаперакладам У загрузках можна будзе абраць субтытры, створаныя аўтаматычна для кожнай мовы. Яны могуць быць недакладнымі і цяжкімі для разумення. Мова субтытраў для загрузкі ў аўтаматычным выбары фармату, падзеленыя коскамі. Запомніць для будучай загрузкі Афармленне Пошук ў субтытрах Інтэрфейс і ўзаемадзеянне Выкарыстаць папярэдні выбар Не, дзякуй Скід Чарга загрузкі адзін некалькі шмат шмат Каб пачаць спампоўку, дакраніцеся да кнопкі спампоўкі або падзяліцеся спасылкай на відэа з гэтай праграмай Усе Выберыце з %1$d спасылак Адсочванне праблем "Сутыкнуліся з памылкай? Перш чым паведаміць аб новай праблеме, пашукайце ў нашым трэкеры праблем. Там ужо разгледжаны і задакументаваны многія агульныя праблемы." Паказаць панэль навігацыі Рэзюмэ Выдаліць Інфармацыя пра СМІ Ліквідацыю непаладак Выпраўце распаўсюджаныя памылкі і праверце вядомыя праблемы Спампавана ================================================ FILE: app/src/main/res/values-bn/strings.xml ================================================ ভিডিও ফোল্ডার অডিও আকারে সেভ করুন থাম্বনেইল সেভ করুন সেটিংস জেনারেল, ফরম্যাট, কাস্টম কমান্ড ডাউনলোড লিংক ফাঁকা রাখা যাবে না ভিডিওর বদলে অডিও ডাউনলোড ও সেভ করুন ভিডিওর থাম্বনেইল ফাইল আকারে সেভ করুন yt-dlp এর লেটেস্ট ভার্সন ব্যবহার করা হচ্ছে ভিডিও ইনফো আনা হচ্ছে… পারমিশন পাওয়া যায় নি ডাউনলোড শেষ হয়েছে ফাইল ডাউনলোড করা যায় নি ভিডিওর ইনফো আনা যায় নি জেনারেল ভাষা একটা ডাউনলোড চলছে ক্লিপবোর্ড থেকে URL পেস্ট করুন কনফার্ম বাতিল ডাউনলোডেড অডিও লিংক ক্লিপবোর্ডে কপি করা হয়েছে লিংক ওপেন করুন সরান ডিলিট ফাইল সম্পর্কে ভার্সন, ফিডব্যাক, অটোমেটিক আপডেট পেছনে যান ভার্সন এডিট yt-dlp\ এর লেটেস্ট ভার্সন ইন্সটল করা যাচ্ছে না। অনুগ্রহ করে ইন্টারনেট কানেকশন চেক করুন। \"%1$s\" ডাউনলোড হিস্ট্রি থেকে মুছে ফেলবেন\? বিস্তারিত ইনফো নতুন ভার্সন এবং চেঞ্জলগ দেখুন কাস্টম কমান্ড চেক করা হয়েছে এই ডাউনলোডটির জন্য কনফিগার করুন ডাউনলোড করার আগে কনফিগার করুন ডাউনলোড করার আগে প্রেফারেন্স সেট করুন GitHub রিপোজিটরি এবং README চেক করুন ক্রেডিটস ক্রেডিট এবং libre সফ্টওয়্যার কাস্টম টেমপ্লেট সহ yt-dlp কমান্ড চালান কমান্ড টেমপ্লেট অ্যাডভান্স ডার্ক থিম, ডাইনামিক কালার, ভাষা ইরর রিপোর্ট ক্লিপবোর্ডে কপি করা হয়েছে থাম্বনেইল পেস্ট করুন লেটেস্ট রিলিজ yt-dlp এর লেটেস্ট রিলিজের জন্য ক্লিক করুন ভিডিও কমান্ড রান করুন বিস্তারিত ইনফো দেখুন যখন ডাউনলোড হবে ডিসপ্লের ভাষা সিলেক্ট করুন ডিলিট? yt-dlp আপডেট করুন অডিও ফরম্যাটে কনভার্ট করুন কনভার্ট ভিডিও ফরম্যাট Yt-dlp ব্যবহারের রেফারেন্স ডাউনলোড বেস্ট কোয়ালিটি Yt-dlp ভার্সন কনভার্ট করা হয়নি বন্ধ করুন \"%1$s\" ডাউনলোড করুন ডিসপ্লে বাতিল করুন আউটপুট পাথ এবং URL অ্যাপ সিলেক্ট করে নিবে। ভিডিও কোয়ালিটি ক্লিপবোর্ড থেকে ভিডিও লিংক নেবার জন্য পেস্ট ক্লিক করুন আপনার ক্লিপবোর্ডের সাথে URL এর মিল পাওয়া যাচ্ছে না ডার্ক থিম সিস্টেম অন অফ কনভার্ট %1$s নির্দিষ্ট করা নেই (ডিফল্ট) পছন্দের ভিডিও ফরম্যাট আবার দেখবো না ওপেন সেটিংস ফরম্যাট অডিও ফাইল আবার এনকোড করলে অডিও কোয়ালিটি নষ্ট হবে আর ফাইল সাইজ বাড়বে। ইউজার গাইড একাধিক ডাউনলোড থাকলে ভিডিওর কোয়ালিটি সেট করুন প্রেফারেড ফরম্যাট যখন একাধিক ডাউনলোড থাকবে ব্যাবহার করার আগে ডাউনলোড সেটিংস দেখুন এবং নিশ্চিত করুন yt-dlp লেটেস্ট ভার্সনে আছে কি না। ভিডিও এবং অডিও ফাইল সহ অ্যাপ-মধ্যস্থ ডাউনলোডগুলি দেখুন এবং পরিচালনা করুন। এরপর সেটিংস ঠিক করে ডাউনলোড এ ক্লিক করুন। প্লেলিস্ট থেকে একাধিক ভিডিও ডাউনলোড করুন ডিফল্টস ডাউনলোড প্লে-লিস্ট ডাউনলোড করুন নতুন কুকির জন্য ওয়েবপেজ খুলতে ট্যাপ করুন: ফাইল এডিট করুন প্লেলিস্ট ডাউনলোড করা হচ্ছে (%1$d/%2$d)… স্পন্সরব্লক দিয়ে সেগমেন্টগুলি সরানোর সময় সাবটাইটেলগুলি এলোমেলো হতে পারে। প্রি রিলিজ ডাউনলোড/ এবং ডকুমেন্টস/ এর বাইরের ডিরেক্টরিগুলি সাপোর্টেড নয় ব্যাকগ্রাউন্ডে ডাউনলোডের জন্য এই অ্যাপটির ব্যাটারি অপ্টিমাইজেশন বন্ধ করুন সিল ডাউনলোড হচ্ছে… GitHub ইস্যু আননোন ইরর উপসর্গ ডাউনলোড টাস্ক বাতিল করা হয়েছে ক্লিপবোর্ডে ইনফো কপি করা হয়েছে SponsorBlock API এর মাধ্যমে ভিডিওতে সেগমেন্টগুলি সরান বা চিহ্নিত করুন আপডেট স্পনসর ব্লক ক্যাটাগরি রানিং টাস্ক ক্লিয়ার দ্বিতীয় অ্যাপে সেভ সব কুকি মুছে ফেলবেন? টেম্পোরারি ডিরেক্টরিতে ফাইল সেভ করুন স্পন্সর ফিডব্যাক GitHub-এ স্পনসর করে এই অ্যাপটিকে সাপোর্ট করুন স্পনসর ইন্টারনেট কানেকশনের জন্য প্রক্সি ব্যবহার করুন প্রক্সি লেগাসি ডিসেবেল করুন ডাউনলোড স্ট্যাটাস এবং প্রোগগ্রেস পোস্ট করার জন্য পারমিশন প্রয়োজন ডিরেক্টরি সেট আপ করতে ট্যাপ করুন ফোল্ডার পিকার ডেভলপার এর বার্তা সবসময়ের জন্য পারমিশন দিন পারমিশন দেবেন না সেলুলার দিয়ে ডাউনলোড করার পারমিশন দিবেন? একাধিক অডিও স্ট্রিম মার্জ করুন একাধিক অডিও স্ট্রিমকে একটি ফাইলে মার্জ করার পারমিশন দিন আপনার ডাউনলোডগুলি এইভাবে সেভ করা হবে: ঠিক আছে বুঝেছি ফাইল খুলুন ডাউনলোড হচ্ছে %1$d টেমপ্লেট(গুলি) এক্সপোর্ট করা হয়েছে রিসেন্টলি অ্যাডেড %1$d টি ভিডিও, %2$d টি অডিও ফাইল বর্তমান ভার্সন আপ টু ডেট কুকিজ টেম্পোরারি ফাইল ক্লিয়ার করুন টেম্পোরারি ডিরেক্টরি থেকে সব টেম্পোরারি ডিলিট করুন %1$d টি টেম্পোরারি ফাইল ডিলিট হয়েছে মাল্টি সিলেক্ট মোড সর্বোচ্চ স্পিড হাই কনট্রাস্ট ডার্ক থিম ইনভ্যালিড ইনপুট আনএভেলেবেল ফাইল ফরম্যাট, ভিডিও কোয়ালিটি, সাবটাইটেল অটো-ট্রান্সলেটেড ক্যাপশন ডাউনলোড করুন এসডি কার্ড ফোল্ডার অটো-ট্রান্সলেটেড ক্যাপশন শর্টকাট এডিট করুন লগ লগ দেখুন শেয়ার করুন স্টেবল ফরম্যাট সর্টিং একাধিক কোয়ালিটি থাকলে অডিও বিটরেট সিলেক্ট করুন মিনিট সব কুকিজ ক্লিয়ার করুন এক্সপেরিমেন্টাল ফিচার চালু করবেন? কোনো মিডিয়া ডাউনলোড করা নেই %1$s বিল্ডগুলির জন্য অটো আপডেট প্রযোজ্য নয়৷ যদি আপনার ডিভাইসে %1$s ইনস্টলড না থাকে, বা সিলের নতুন ফিচার দেখতে চান, অনুগ্রহ করে %2$s বিবেচনা করুন সাবটাইটেল কনভার্ট করুন কোন কাস্টম কমান্ড নেই উফ! কনো ভুল হয়েছে ভিডিওটি %1$d টি পার্টে ভাগ করা হবে কপি করে বের হন এক্সপ্যান্ড করুন নতুন ডাউনলোড টাস্ক ইউজার-এজেন্ট হেডার বাতিল হয়া শুরু শেষ সাবটাইটেল ফাইল রাখুন ডাইনামিক কালার %1$d টাস্ক ডাউনলোড করুন ডাউনলোডের জন্য সর্বোচ্চ স্পিড সিলেক্টে করুন রেট লিমিট, ডাউনলোডার, কুকিজ %1$d সিলেক্ট করা হয়েছে কাস্টম কমান্ড ব্যবহার করার সময় কিছু অপশন বন্ধ থাকবে ইন্টারনেট স্পিড লিমিট সাবটাইটেলের ভাষা ভাষা, এম্বেড সাবটাইটেল, অটো ক্যাপশন ভিডিও ক্রিয়েটরের স্যাম্পল টেক্সট সাবটাইটেল ডাউনলোড করুন চ্যানেল আপডেট করুন শুরু করুন \"%1$s\" এডিট করুন স্প্লিট ভিডিও অটো কমান্ড আননোন ডাউনলোড শুরু করার আগে ফরম্যাট সিলেক্ট করুন গোপনীয়তা অডিও ফোল্ডার ক্লিপবোর্ডে এক্সপোর্ট করুন সাবডিরেক্টরিতে সেভ করুন সাবটাইটেল এম্বেড করুন কুকিজ ব্যবহার করুন ভিডিওতে সাবটাইটেল এম্বেড করুন (যদি থাকে) ইনফো আনা হচ্ছে আরো অ্যাকশন দেখুন ইনডেক্স ভ্যালিড নয় শেয়ার করা কনটেন্ট থেকে URL ম্যাচ করা যায়নি ভিডিও লিঙ্ক হোস্টেড ওয়েবলেটে এই অ্যাপটি ট্রান্সলেট করতে সাহায্য করুন ক্লিপবোর্ড থেকে ইমপোর্ট করুন আপনার ডাউনলোড হিস্ট্রি থেকে %1$d আইটেম(গুলি) সরাবেন? এক্সটারনাল ডাউনলোডার হিসাবে aria2c ব্যবহার করুন সেলুলার নেটওয়ার্কের দিয়ে ডাউনলোড আপনার সেটিংস অনুযায়ী বন্ধ করা ফরম্যাট সিলেক্ট টেলিগ্রাম চ্যানেল ব্যাকগ্রাউন্ডে ডাউনলোড করার জন্য অনুগ্রহ করে সিস্টেম সেটিংসে এই অ্যাপের ব্যাটারি ব্যবহারকে \"Unrestricted\" এ সেট করুন। ডাউনলোড নোটিফিকেশন বাতিল হয়েছে কাস্টম কমান্ড ব্যবহার করুন ভিডিও (অডিও ছাড়া) সাজেস্টেড এটা কিভাবে কাজ করে? অপশন সেলুলার ব্যবহার করে ডাউনলোড করুন মিটারর্ড নেটওয়ার্ক ব্যাবহার করে মিডিয়া ডাউনলোড করার পারমিশন দিন সব সিলেক্ট করুন ডাউনলোডেড ফাইল এবং প্রোগ্রেস দেখুন ডাউনলোড শেষ। দেখতে ট্যাপ করুন। কাস্টম কমান্ড রান হচ্ছে… মাল্টি-থ্রেডেড ডাউনলোড %d থ্রেড(গুলি) একসাথে DASH/HLS নেটিভ ভিডিও ডাউনলোড করতে ব্যবহার করা হবে। অতিরিক্ত সেটিংস ডাউনলোড করা ফাইল এবং প্রোগগ্রেস সম্পর্কে জানুন একইসাথে M3U8/MPD ভিডিওর আরও পার্ট ডাউনলোড করুন শেয়ার করা কন্টেন্ট থেকে ভিডিও লিঙ্ক নেয়া হচ্ছে… প্লেলিস্টের ইনফো নেয়া হচ্ছে… প্লেলিস্ট সিলেক্ট শুরু শেষ ডাউনলোড ডিরেক্টরি ভিডিও এবং অডিও ফাইলগুলি কোথায় সেভ হবে তা সিলেক্ট করুন নির্দিষ্ট নামে ফোল্ডারে ফাইল সেভ করুন স্টোরেজ পারমিশনে সমস্যা ব্যাটারি কনফিগারেশন ট্রান্সলেট নতুন টেমপ্লেট ডাউনলোডের সময় থাম্বনেইল বন্ধ করুন নতুন কুকি তৈরি করুন \"%1$s\" এর জন্য এই এন্ট্রিটি সরাবেন? করে মনে রাখবেন যে এই সাইটের জন্য সেভড কুকিগুলি ক্লিয়ার করা হবে না প্রয়োজন %d টি আইটেম %d টি আইটেম টেমপ্লেট সিলেক্ট রিমুভ? কিউ করা হয়েছে হয়ে গেছে প্রাইভেট মোড প্রি-ভিউ বন্ধ করুন হিডেন ডিরেক্টরিতে ডাউনলোড করুন ডাউনলোড হিস্ট্রি বন্ধ করুন ওয়ালপেপার থেকে অ্যাপ থিম সেট করুন ফাইলটি নেই ক্রপ আর্টওয়ার্ক এমবেডড ছবি ক্রপ করুন প্লেলিস্ট \"%1$s\" থেকে ডাউনলোড করতে ভিডিও সিলেক্ট করুন সীল সর্বদা ফ্রি এবং ওবদ-সোর্স থাকবে। আপনার যদি প্রোজেক্টটি পছন্দ হয়, অনুগ্রহ করে আমাকে GitHub-এ স্পনসর করার কথা বিবেচনা করবেন URL থেকে ভিডিও ডাউনলোড করুন লিংক কপি করুন ইরর রিপোর্ট ভিডিও রেজুলেশন ভিডিও ফাইলের সাইজ "%.2f এমবি" %.2f জিবি অ্যাপ্লাই ভিডিও ক্লিপ করুন প্রেফারেড অডিও ফরম্যাট ফাইল ক্লিপবোর্ড থেকে ইমপোর্টেড ইমপোর্ট ডাউনলোড হিস্ট্রি ইমপোর্ট করবেন? ডাউনলোড হিস্ট্রি এক্সপোর্ট করবেন? ফুল ব্যাকআপ ব্যাকআপের ধরণ এক্সপোর্ট করুন ডাউনলোড হিস্ট্রি থেকে %1$s এক্সপোর্ট করা হচ্ছে। ডাউনলোড করা ফাইল এবং প্রেফারেন্স ব্যাকআপ হবে না ফাইল এক্সপোর্ট করুন ফাইলের নাম রিস্ট্রিক্ট করুন কম্পিটিবিলিটি নিশ্চিত করতে নির্দিষ্ট অক্ষরে ফাইলের নাম সীমাবদ্ধ করুন ডাউনলোড হচ্ছে… রিস্টার্ট ইরর ইমপোর্ট করা %1$d টেমপ্লেট(গুলি) লেটেস্ট ভার্সন আপডেট করতে ব্যর্থ লোএস্ট কোয়ালিটি প্রাইভেট ডিরেক্টরি ম্যাট্রিক্স স্পেস কুইক ডাউনলোড ভিডিও টাইটেলের নমুনা সাবটাইটেল অ্যাড করুন অটোমেটিক আপডেট আনলিমিটেড সর্বনিম্ন বিটরেট অডিও কোয়ালিটি yt-dlp-এর -S অপশন দিয়ে ফরম্যাট সর্টিং ইমপোর্ট টাইটেল রিনেম করুন অডিও ফরম্যাট ফরম্যাট সিলেক্ট পেজে ভিডিও ক্লিপ তৈরি করুন GitHub বিল্ডে স্যুইচ করা হচ্ছে ফিচার আনএভেলেবেল আপনাকে অনেক ধন্যবাদ! কোয়ালিটি নোটিফিকেশন চালু করবেন? কাস্টম কমান্ড ডিরেক্টরি বন্ধ করা অন্যান্য অ্যাপে শেয়ার করার জন্য MP4(H.264) ফরম্যাট সিলে করুন সাপোর্টেড অ্যাপে দেখার জন্য AV1, VP9 বা H.265 ফরম্যাট সিলেক্ট করুন কাস্টম ফরম্যাট প্রেফারেন্স আরও জানুন আউটপুট টেমপ্লেট আউটপুট ফাইলের নামের জন্য টেমপ্লেট সিলেক্ট করুন অটো-ট্রান্সলেটেড সাবটাইটেল সব ভাষার জন্য অটো-ট্রান্সলেটেড সাবটাইটেল ডাউনলোডে করা যাবে। এই সাবটাইটেলগুলি বোঝা কঠিন এবং ভুল হতে পারে সাবটাইটেলগুলির ভাষা (,) কমা দিয়ে আলাদা করা পরবর্তী ডাউনলোডের জন্য একই রাখবেন সিস্টেম সেটিংস লুক অ্যান্ড ফিল ইন্টারফেস এবং ইন্টারঅ্যাকশন পূর্ববর্তী সিলেকশন ব্যবহার করুন কোনোটাই নয় রিসেট সাবটাইটেল সার্চ করুন না ধন্যবাদ সাবটাইটেলের ভাষা আপডেট করবেন? হিস্ট্রি ডাউনলোড করুন আর্কাইভে ডাউনলোড করুন ডাউনলোড আর্কাইভে ক্লিয়ার করবেন? আর্কাইভে ফাইল থেকে %1$s রিমুভ করতে চান? IPv4 ফোর্স করুন IPv4 এর মাধ্যমে সমস্ত কানেকশন রাউট করুন লেবেল প্লেলিস্ট \"%3$s\" (%1$d থেকে %2$d পর্যন্ত) ডাউনলোড করার জন্য ভিডিওগুলির রেঞ্জ নির্দিষ্ট করুন। কমান্ড টেমপ্লেট থেকে \"%1$s\" সরাতে চান? কমান্ড টেমপ্লেট এডিট এবং পরিচালনা করুন বাগ রিপোর্ট বা ফিচার রিকুয়েস্ট জন্য একটি ইস্যু রিপোর্ট করুন ভিডিও থেকে সরানোর জন্য SponsorBlock সেগমেন্টগুলি সিলেক্ট করুন আপডেট চেক করুন অটোমেটিক GitHub-এর লেটেস্ট ভার্সন চেক করুন বাতিল করা ডাউনলোড পুনরায় শুরু করতে টেম্পোরারি ফাইল ব্যবহার করা যেতে পারে। আপনি কি ফাইলগুলি মুছে ফেলবেন? \n \nআপনি %1$s-এ এই ফাইলগুলি অ্যাক্সেস করতে পারেন Yt-dlp ভার্সন, নোটিফিকেশন, প্লেলিস্ট কিছু সাইট থেকে ডাউনলোড করার জন্য অ্যাকাউন্ট আথেন্টিকেশন তথ্য প্রয়োজন \"নতুন কুকি তৈরি করুন\" ক্লিক করুন, ওয়েবসাইটের URL লিখুন এবং ব্রাউজার পেজে আপনার অ্যাকাউন্ট লগ ইন করুন বেশিরভাগ ভিডিও স্ট্রিমিং প্ল্যাটফর্মগুলি আলাদাভাবে অডিও আর ভিডিও সরবরাহ করে, আপনি একটি ভিডিও (অডিও ছাড়া) ফরম্যাটের সাথে একটি অডিও মার্জ করতে পারেন লগ কপি করুন কাস্টম শর্টকাটগুলি এডিট করুন যা কমান্ড টেমপ্লেট তৈরিতে ব্যবহার করা যেতে পারে ডাউনলোডের জন্য নেটস্কেপ ফরম্যাটেড কুকিজ ব্যবহার করুন yt-dlp ভিডিও ডাউনলোড করার জন্য একটি শক্তিশালী কমান্ড-লাইন টুল, সিল একটি GUI সাধারণ কমান্ডের জন্য প্রিসেট এবং অন্যান্য অতিরিক্ত ফিচার প্রদান করে yt-dlp ব্যবহার সহজ করে তোলে \n \nঅ্যাভান্স শর্টকাট সাবটাইটেল এম্বেড করতে, ভিডিওগুলো mkv কন্টেইনারে রিমাক্স করা হবে। এমবেডেড সাবটাইটেল সহ ভিডিও দেখতে আপনি VLC মিডিয়া প্লেয়ার বা অন্যান্য সামঞ্জস্যপূর্ণ অ্যাপ ব্যবহার করতে পারো। নতুন ফিচার এবং পরিবর্তনের বিষয় দেখতে প্রি-রিলিজ বিল্ড ইনস্টল করুন \n \nএই ভার্সনগুলিতে কিছু সমস্যা থাকবে, কোনো সমস্যা দেখা দিলে আমাদের ফিডব্যাক জানাতে দ্বিধা করবেন না আটোমেটিক আপডেট চালু করুন বেটা এই ফিচার ব্যবহার করে ডাউনলোড করা ভিডিওর সিলেক্টেড সেগমেন্টগুলি ডাউনলোড করতে FFmpeg-এর ব্যাবহার করা হবে, এই ফিচার এখনও পরীক্ষামূলক এবং কাটিং পুরোপুরি ঠিক হবে না সাবটাইটেলগুলিকে অন্য ফরম্যাটে রূপান্তর করুন কাস্টম কমান্ড ব্যবহার করার সময় আউটপুট ডিরেক্টরি সিলেক্ট করুন কমান্ড টেমপ্লেট থেকে %1$s সরাতে চান? প্রিসেট মোট %1$d টি আইটেম দেখুন ওয়েবসাইট ডুপ্লিকেট ডাউনলোড এড়াতে একটি ডাইরেক্টরিতে ডাউনলোড করা ভিডিও আইডি রেকর্ড করুন সেভ প্লেলিস্ট টাইটেল ডাউনলোড টাইপ মেটাডেটা এম্বেড করুন অডিও ফাইলে মেটাডেটা এবং ভিডিও থাম্বনেল এম্বেড করুন ফরম্যাটেড সর্টিং ব্যবহার করুন নিম্নলিখিত ভাষাগুলি ভবিষ্যতে ডাউনলোডের জন্য আপনার প্রেফারেন্সে অ্যাড করা হবে: একবার পারমিশন দিন এক্সপোর্ট ডাউনলোড করা ফাইল ইমপোর্ট করা হবে না। আপনাকে সেগুলি ম্যানুয়ালি ডাউনলোড করতে হবে ভিডিওটি ডাউনলোড করা হয়েছে। এটি প্রত্যাশিত না হলে, আপনার ডাউনলোড ডিরেক্টরি চেক করুন। হিস্ট্রি ডাউনলোড করতে %1$s ইমপোর্ট করা হয়েছে আবার ডাউনলোড করুন আপনার ডাউনলোডগুলি এখানে দেখানো হবে ডাউনলোড বাটনে ক্লিক করুন অথবা একটি ভিডিও লিঙ্ক অ্যাপে শেয়ার করে ডাউনলোড শুরু করুন ডাউনলোড সার্চ করুন সার্চ করুন ডাউনলোড কিউ সব রিমুএক্স ভিডিও কন্টেইনার প্রতি দিন ভালো প্লেব্যাকের জন্য ভিডিওগুলো MKV কন্টেইনারে রিমুএক্স করুন %2$d টি ওয়েবসাইটের মোট %1$d টি কুকি প্রতি মাসে প্রতি সপ্তাহে ================================================ FILE: app/src/main/res/values-ca/strings.xml ================================================ Quant a Versió, retroalimentació, actualització automàtica Ves enrere Carpeta dels vídeos Desa com a àudio General, format, ordre personalitzada Baixa L\'enllaç no pot estar buit Baixa i desa l\'àudio, en lloc del vídeo Desa la miniatura del vídeo com a fitxer No s\'ha pogut instal·lar la versió més recent de «yt-dlp». Assegureu-vos que esteu connectat a Internet. S\'està recuperant la informació del vídeo… Baixa «%1$s» No s\'ha pogut obtenir la informació del vídeo Estableix l\'idioma de visualització Mostra l\'idioma Versió de yt-dlp Feu clic per instal·lar l\'última versió de yt-dlp Actualitza el yt-dlp Voleu eliminar-lo? Esteu segur que voleu eliminar «%1$s» de l\'historial de baixades? Obre l\'enllaç Elimina Versió Cerca registres de canvis i noves versions Vídeo Marcat Crèdits Crèdits i programari lliure Ordre personalitzada Executa l\'ordre yt-dlp amb una plantilla personalitzada Plantilla de l\'ordre Mostra missatges detallats durant la baixada Tema fosc, color dinàmic, idiomes Desactivat Cancel·la Configura abans de baixar Configura les preferències abans de baixar Ajusta aquesta baixada S\'ha copiat l\'informe d\'error al porta-retalls Enganxa Referències d\'ús de yt-dlp L\'aplicació afegirà el camí de sortida i l\'URL. Converteix el format d\'àudio Sense conversió Converteix a %1$s Format La recodificació dels fitxers d\'àudio provocarà pèrdues de qualitat d\'àudio i l\'augment de mida del fitxer. Qualitat del vídeo Limita la qualitat del vídeo quan n\'hi ha més d\'un Format de vídeo preferit Format preferit quan se\'n proporciona més d\'un Desa la miniatura Paràmetres S\'està fent servir l\'última versió de yt-dlp Permís denegat Suprimeix el fitxer S\'ha finalitzat la baixada No s\'ha pogut baixar el fitxer General Ja hi ha una tasca de baixada executant-se en aquests moments Enganxa l\'URL des del porta-retalls No s\'ha pogut detectar l\'URL del porta-retalls Confirma Baixades Àudio Cancel·la S\'ha copiat l\'enllaç al porta-retalls Últim llançament Comprova el repositori de GitHub i el README Aspecte Edita Inicia l\'execució de l\'ordre Sistema Avançat Sortida detallada Tema fosc Activat Miniatura Millor qualitat No especificat (predeterminat) Format de vídeo Converteix Baixa Tanca No ho tornis a mostrar Guia d\'usuari Obre la configuració Feu clic a «Enganxa» per a obtenir l\'enllaç del vídeo del porta-retalls. Feu un cop d\'ull a la configuració de les baixades i assegureu-vos que teniu l\'última versió de yt-dlp abans d\'utilitzar l\'aplicació. Baixa una llista de reproducció Per defecte Notifica els fitxers baixats i el progrés Enllaç del vídeo S\'ha finalitzat la baixada. Toqueu per obrir. S\'estan executant les ordres personalitzades… Establiu l\'ús de la bateria d\'aquesta aplicació a \"Sense restriccions\" a la configuració del sistema per poder baixar vídeos en segon pla. Baixada multitasca A continuació, feu clic a «Baixa» després d\'ajustar la configuració. Comprova i gestiona les baixades a l\'app, inclosos els vídeos i els fitxers d\'àudio. Baixa múltiples vídeos d\'una llista de reproducció Baixa Baixa més parts de vídeos M3U8/MPD en paral·lel S\'usarien %d fils per baixar vídeo nadiu DASH/HLS concurrentment. Opcions Configuració addicional No s\'ha pogut obtenir l\'URL del contingut compartit Mostra més accions Tiquet GitHub Creeu un tiquet per a un informe d\'error o per a una petició de nova funcionalitat S\'ha copiat la informació al porta-retalls S\'han importat %1$d plantilles %1$d tasques de baixada Voleu eliminar %1$d elements de l\'historial de baixades? Categories de SponsorBlock Comprova si hi ha actualitzacions Comprova automàticament si hi ha una nova versió a GitHub Ja teniu l\'última versió Actualitza Baixa dades multimèdia també quan estiguis connectat a xarxes amb control de consum La baixada amb la xarxa mòbil està desactivada d\'acord amb la configuració Aquest fitxer ja no està disponible Xarxa Límit de la velocitat de baixada Limita la velocitat màxima de baixada Velocitat màxima Tema fosc d\'alt contrast No disponible Límit de baixada, eina de baixada, galetes Desactiva la vista prèvia No es mostren les miniatures durant la baixada Confidencialitat Escapça les cobertes Escapça la imatge incrustada al quadrat Utilitza galetes Voleu eliminar aquesta entrada per a «%1$s»? No s’esborraran les galetes emmagatzemades del lloc. Algunes opcions no estan disponibles quan s\'utilitza una ordre personalitzada Com funciona? Canal Telegram Carpeta de targetes SD La majoria de plataformes de vídeo en streaming envien l\'àudio i el vídeo per separat, podeu seleccionar i fusionar els formats de només àudio i només vídeo a un sol fitxer de vídeo. Text de mostra del creador de vídeo Copia el registre S\'està llegint l\'enllaç del vídeo des del contingut compartit… Baixa la notificació Selecció de la llista de reproducció Notifica sobre els fitxers baixats i el progrés S\'està recuperant la informació de la llista de reproducció… Especifiqueu l\'interval de vídeos a baixar de la llista de reproducció «%3$s» (des de %1$d a %2$d). Inici Interval d\'índex no vàlid Final Seleccioneu on voleu emmagatzemar els vídeos i els fitxers d\'àudio S\'està baixant la llista de reproducció (%1$d/%2$d)… Carpeta d\'àudios Directori de baixades Desa-ho en un subdirectori Desa els fitxers en carpetes anomenades com als camps respectius Problema de permisos d\'emmagatzematge Els directoris que són fora de «Download» i «Documents» no estan suportats Configuració de la bateria Ignora l\'optimització de la bateria perquè aquesta aplicació pugui baixar vídeos en segon pla Seal està descarregant… Tradueix Ajudeu a traduir aquesta aplicació a Hosted Weblate Error desconegut Prefix Incrusta els subtítols Incrusta els subtítols als vídeos si és possible Nova plantilla Etiqueta Ho voleu eliminar? Voleu eliminar «%1$s» de les plantilles d\'ordres? Selecció de plantilla Editeu i gestioneu les plantilles d\'ordres S\'ha cancel·lat la tasca de baixada En cua Baixada en curs… Completat S\'està baixant Cancel·lat S\'està recuperant la informació Obre un fitxer Copia l\'enllaç Mida del fitxer del vídeo Importa des del porta-retalls Reinicia Error Copia l\'informe Resolució del vídeo Exporta al porta-retalls S\'han exportat %1$d plantilles %1$d vídeos, %2$d fitxers d\'àudio Afegit recentment Elimina o marca els segments dels vídeos amb l\'API de SponsorBlock S\'han suprimit %1$d fitxers temporals Especifiqueu les categories SponsorBlock per a eliminar o marcar en el fitxer de vídeo Galetes No s\'ha pogut actualitzar a la versió més recent Utilitza l\'aria2c com a eina de baixada externa Utilitza galetes amb format Netscape per a les baixades Neteja els fitxers temporals Els fitxers temporals poden ser útils per reprendre les baixades cancel·lades. Esteu segur que voleu suprimir tots aquests fitxers? \n \nPodeu accedir a aquests fitxers a %1$s Suprimeix tots els fitxers temporals del directori temporal Mode de selecció múltiple Incògnit Color dinàmic Desactiva l\'historial de baixades Aplica els colors dels fons de pantalla al tema de l\'aplicació Baixa utilitzant les dades mòbils Entrada no vàlida Usa una ordre personalitzada Directori privat Emmagatzema les baixades en un directori amagat %1$d seleccionat Qualitat més baixa Format del fitxer, qualitat del vídeo, subtítols Seleccioneu els vídeos a baixar de la llista de reproducció «%1$s» Selecciona-ho tot Versió del yt-dlp, notificacions, llistes de reproducció Vídeo (sense àudio) Suggerit Selecció del format Seleccioneu el format a baixar abans d\'iniciar la baixada Genera galetes noves La baixada des d\'alguns llocs requereix informació d\'autenticació del compte. Feu clic a «Generar galetes noves», introduïu l\'URL del lloc web i després inicieu sessió amb el vostre compte amb el navegador, l\'app l\'obrirà per tu. Subtítols automàtics Baixa els subtítols autogenerats Baixada ràpida Idiomes dels subtítols Text de mostra del títol del vídeo Subtítol Idiomes, subtítols incrustats, subtítols automàtics Edita les dreceres Baixa els subtítols Neteja Afegeix Dreceres yt-dlp és una potent eina de línia d\'ordres per baixar vídeos. Seal facilita l\'ús de yt-dlp proporcionant una interfície gràfica intuïtiva, ordres predefinides s i altres funcions addicionals. \n \nPer a un ús avançat de yt-dlp, Seal permet crear, desar i executar plantilles d\'ordres personalitzades directament, com en un terminal. \n \nQuan s\'utilitzen ordres personalitzades, la majoria de les opcions i funcions de la interfície gràfica es desactivaran. Edita les dreceres personalitzades que es poden utilitzar per a compondre plantilles d\'ordres. Tasques que s\'estan executant Mostra el registre %.2f GB Estable Previsualitza Actualitza automàticament Activa l\'actualització automàtica Descarta Retalla el vídeo Inicia Finalitza Format d\'àudio preferit Sense límit Qualitat de l\'àudio Ordenació per format Ordena els formats amb l\'opció -S de yt-dlp Emmagatzema els fitxers temporals al directori intern Patrocina Doneu suport a aquesta aplicació patrocinant-la a GitHub Comentaris Patrocinadors No s\'ha baixat cap fitxer multimèdia Beta Entesos Converteix els subtítols Aquesta funcionalitat no està disponible Missatge del desenvolupador Divideix el vídeo Nova tasca de baixada Edita \"%1$s\" Qualitat Voleu habilitar les notificacions? Desactivat Personalitzat Selector de carpetes Tipus de baixada Automàtic Ordres Preferència de format Més informació Plantilla de sortida Baixa l\'arxiu Restringeix els noms de fitxer Força IPv4 Permet una vegada Taxa de bits més baixa Neteja totes les galetes Voleu suprimir totes les galetes emmagatzemades a l\'app per sempre? Canal de Matrix Registre Actualitza el canal Es poden malmetre els subtítols en eliminar els segments de SponsorBlock. Instal·leu les versions de prellançament per provar les noves funcionalitats i canvis. \n \nHi haurà certa inestabilitat en aquestes versions, així que no dubteu a enviar-nos qualsevol comentari si experimenteu algun problema per ajudar-nos a millorar l\'aplicació. Per incrustar subtítols, els vídeos es barrejaran en un contenidor MKV. Podeu utilitzar el Reproductor VLC o altres aplicacions compatibles per veure vídeos amb subtítols incrustats. %.2f MB Comparteix Aplica Limita la taxa de bits de l\'àudio quan hi ha diverses qualitats Importa Títol Canvia el nom segon minut Incrusta les metadades Edita el fitxer Títol de la llista de reproducció Configuració del sistema Permet sempre No permetis Copia i surt El Seal serà sempre lliure i de codi obert per a tothom. Si us agrada, considereu patrocinar-me a GitHub! Format d\'àudio Voleu habilitar la funcionalitat experimental? Fes els videoclips a la pàgina de selecció de format Expandeix D\'acord Inicia Servidor intermediari Llegat Capçalera de l\'agent d\'usuari Desactiva Desconegut Lloc web Valors predefinits Requerit Desa s\'està canviant a les versions de GitHub No hi ha tasques d\'ordre personalitzades Moltes gràcies! Baixa els vídeos de l\'URL Converteix els subtítols a un altre format Vaja! Alguna cosa ha anat malament El vídeo es dividirà en %1$d capítols Utilitza el servidor intermediari per a les connexions a Internet Toqueu per configurar el directori L\'aplicació necessita el vostre permís per publicar notificacions sobre l\'estat de baixada i el progrés. Directori d\'ordres personalitzat Especifiqueu el directori de sortida quan s\'utilitzin ordres personalitzades Prefereix els formats MP4 (H.264) per compartir fitxers amb altres aplicacions Prefereix els formats AV1, VP9 o H.265 per poder reproduir-los en aplicacions compatibles Toqueu per obrir la pàgina web per generar noves galetes: Exporta a un fitxer Voleu netejar l\'arxiu de les baixades? Voleu eliminar %1$s al fitxer d\'arxiu per sempre? Enregistra els identificadors dels vídeos baixats en un arxiu per evitar baixades duplicades Incrusta les metadades i la miniatura del vídeo al fitxer d\'àudio Mostra tots els %1$d elements Utilitza l\'ordenació per format Les baixades es desaran com a: Manté els fitxers de subtítols Voleu permetre la baixada amb dades mòbils? %d element %d elements %d elements Fes totes les connexions via IPv4 Permet fusionar múltiples fluxos d\'àudio en un sol fitxer Fusiona múltiples fluxos d\'àudio L\'actualització automàtica no està disponible per a les versions de %1$s. Si no teniu %1$s instal·lat al dispositiu, o voleu previsualitzar les futures funcionalitats del Seal, considereu %2$s. Les baixades que utilitzin aquesta funció es delegaran a FFmpeg per baixar les seccions seleccionades del vídeo. Aquesta funció encara és experimental i el tall no serà completament precís, i no tots els formats suporten aquesta característica. És possible que experimenteu velocitats de baixada més lentes. Especifiqueu la plantilla per als noms dels fitxers de sortida Voleu eliminar %1$s de les plantilles d\'ordres per sempre? Limita els noms de fitxer a caràcters específics per garantir la compatibilitat Totes les llengües Llista de reproducció Continua S\'ha afegit la tasca a la cua Baixa el millor format disponible No, gràcies Porta-retalls Importa des de Voleu exportar l\'historial de baixades? Voleu importar l\'historial de baixades? Baixa l\'historial Voleu actualitzar les llengües dels subtítols? Importa Fitxer Exporta Subtítols traduïts automàticament Recorda-ho per a la pròxima baixada Interfície i interacció Cap Reinicialitza Cerca als subtítols Aspecte Usa la selecció anterior Diàriament Setmanalment Mensualment Baixa novament Cerca Cerca a les baixades ================================================ FILE: app/src/main/res/values-ckb/strings.xml ================================================ پێشکەوتوو فۆڵدەری ڤیدیۆ وەکوو دەنگ پاشەکەوتی بکە پاشەکەوتکردنی وێنەی سەر ڤیدیۆ ڕێکخستنەکان گشتی، شێواز، فەرمانی تایبەت داگرتن ناکرێت بەتاڵ بێت وێنەی سەر ڤیدیۆ وەک فایلێک پاشەکەوت بکە بەکارهێنانی نوێترین وەشانی yt-dlp وەرگرتنی زانیاریی ڤیدیۆ… داگرتن تەواو بوو داگرتنی \"%1$s\" نەتواندرا زانیاریی ڤیدیۆ وەربگیرێت گشتی زمانی ڕووکار زمانی ڕووکار دیاری بکە بەستەری لە کلیپبۆردەوە بلکێنە نەتواندرا لەگەڵ بەستەری ناو کلیپبۆردەکەتدا بگونجێت بۆ دامەزراندنی نوێترین وەشانی yt-dlp کرتە بکە نوێکردنەوەی yt-dlp لایدەبەیت؟ دڵنیاکردنەوە هەڵوەشاندنەوە دابەزاندنەکان دەنگ بەستەرەکە کۆپی کرا بۆ کلیپبۆرد لینک بکەرەوە لایببە فایل بسڕەوە دەربارە گەڕانەوە وەشان بەدوای گۆڕانکاری و وەشانی نوێدا بگەڕێ نوێترین بڵاوکراوە مۆڵەت مۆڵەت و نەرمەکاڵای لیبرێ فەرمانی تایبەت دەستکاری دەست بکە بە جێبەجێکردنی فەرمان دەرئەنجامی ورد لە کاتی دابەزاندندا پەیامی ورد پیشان بدە ڕووکار ڕووکاری تاریک سیستەم چالاک ناچالاک پاشگەزبوونەوە پێش دابەزاندن ڕێکی بخە ئەم دابەزاندنە ڕێک بخە ڕاپۆرتی کێشە کۆپی کرا بۆ کلیپبۆرد لکاندن ئاماژەکانی بەکارهێنانی yt-dlp ڕێڕەوی دەرەنجام و بەیتەر لەلایەن بەرنامەکەوە زیاد دەکرێن. گۆڕینی فۆڕماتی دەنگی نەگۆڕاو فۆڕمات کوالێتیی ڤیدیۆ باشترین کوالێتی کوالێتیی ڤیدیۆکە سنووردار بکە کاتێک چەند ڤیدۆیەک هەبن دیارینەکراو (بچینەیی) فۆڕماتی ڤیدیۆیی خوازراو فۆڕماتی ڤیدیۆ گۆڕین دابەزاندن داخستن ڕێنوێنیی بەکارهێنەر بەستەری ڤیدیۆکە بلکێنە باخود لە کلیپبۆردەوە بیهێنە دابەزاندنەکانی نێو بەرنامەکە بپشکنە و بەڕێوەیان ببە، بە فایلە ڤیدیۆیی و دەنگییەکانەوە. پلەیلیست داببەزێنە کۆمەڵە ڤیدیۆیەک لە پلەیلیستێکەوە دابەزێنە بنچینەیی دابەزاندن ئاگادارکردنەوە لە فایلە دابەزێندراوەکان و پێشکەوتنەکەیان بەستەری ڤیدیۆ جێبەجێکردنی فەرمانە تایبەتەکان… بابەتی %d %d بابەتە لەجیاتی ڤیدیۆ وەک دەنگ دایبگرە نەتوانرا نوێترین وەشانی yt-dlp دابمەزرێنێت. تکایە دڵنیابە کە پەیوەستیت بە ئینتەرنێتەوە. مۆڵەت ڕەتکرایەوە نەتواندرا فایل داببەزێندرێت لە ئێستایا ئەرکێکی دابەزاندن کارایە \"%1$s\" لە مێژووی دابەزاندنەکەت بۆ هەمیشە لادەبەیت؟ وەشانی yt-dlp کۆگای گیتھەب و فایلی README بپشکنە ڤیدیۆ وەشان، بۆچوون، نوێکردنەوەی خۆکارانە پشکنرا فەرمانی yt-dlp بە فەرمانی تایبەتەوە جێبەجێ بکە داڕێژەی فەرمان پێش دابەزاندن هەڵبژاردنەکان ڕێک بخە وێنەی سەر ڤیدیۆ ڕووکاری تاریک، ڕەنگی جووڵەدار، زمانەکان بیگۆڕە بۆ %1$s دووبارە کۆدکردنەوەی فایلە دەنگییەکان دەبێتە هۆی لەدەستدانی کوالێتیی دەنگ و زیادبوونی قەبارەی فایلەکان. فۆڕماتی خوازراو کاتێک چەند ڤیدیۆیەک هەبن جارێکی تر پیشانی مەدە سەیری ڕێکخستنەکانی دابەزاندن بکە و دڵنیابە کە نوێترین وەشانی yt-dlpت هەیە پێش بەکارهێنانی. ڕێکخستنەکان بکەرەوە پاشان دوای ڕێکردنی ڕێکخستنەکان کلیک لە \"دابەزاندن\" بکە. داگرتن تەواو بوو. پەنجە بنێ بۆ کردنەوەی. تکایە بەکارهێنانی بارگاییی ئەم بەرنامەیە لە ڕێکخستنەکانی سیستەمدا لەسەر \"Unrestricted\" دابنێ بۆ ئەوەی لە پشتەوە دابەزاندن بکات. دابەزاندنی فرەڕەگ %d thread(s) بەکاردەهێنرێت بۆ دابەزاندنی ڤیدیۆی ڕەسەنی DASH/HLS لە یەک کاتدا. هەڵبژاردەکان بەشی زیاتری ڤیدیۆکانی M3U8/MPD بە شێوەیەکی هاوتا دابەزێنە ڕێکخستنە زیادەکان ناتوانرێت بەستەر لەگەڵ ناوەڕۆکە هاوبەشەکەوە هاوتا بکرێت خوێندنەوەی بەستەری ڤیدیۆ لە ناوەڕۆکی هاوبەشەوە… کرداری زیاتر پیشان بدە ئاگاداریی دابەزاندنەکان ئاگادارم بکەوە لە فایلە دابەزێنراوەکان و دابەزاندنەکانی ئێستا بەردەستخسنی زانیاریی پلەیلیست… هەڵبژاردنی پلەیلیست دەستپێکردن کۆتایی مەودای پێنوێنی هەڵەیە دیاری بکە لە کوێ ڤیدیۆ و فایلە دەنگییەکان هەڵبگیرێت هەڵگرتن بۆ ڕێبەر فایلەکان لە فۆڵدەرەکاندا هەڵبگرە کە وەک بواری تایبەت بە خۆیان ناویان لێنراوە کێشەی مۆڵەتی هەڵگرتن ڕێکخستنی پاتری پشتگوێخستنی باشکردنی پاتری بۆ ئەم ئەپە بۆ ئەوەی لە پاشبنەمادا دایبەزێنیت Seal خەریکی دابەزاندنە… هەلەیەکی نەناسراوە وەرگێڕان یارمەتی وەرگێڕانی ئەم ئەپە بدە لە Hosted Weblate پێشگر ژێرنووس بخەرە ناو ڤیدیۆکانەوە، ئەگەر بەردەست بوو قاڵبی نوێ نیشانە مەودای ڤیدیۆکان دیاری بکە بۆ دابەزاندن لە پلەی لیستی \"%3$s\" (لە %1$d بۆ %2$d). دابەزاندنی پلەی لیست (%1$d/%2$d)… شوێنەکانی دەرەوەی فۆڵدەرەکانی Download/ و Documents/ پشتگیری ناکرێت ژێرنووسەکان جێگیر بکە فۆڵدەری دەنگ شوێنی دابەزاندن لایدەبەیت؟ \"%1$s\" لە قاڵبی فەرمانەکان لادەبەیت بۆ هەمیشە؟ هەڵبژاردنی قاڵب دەستکاریکردن و بەڕێوەبردنی قاڵبی فەرمانەکان داگرتن لە جێبەجێکردندایە… کێشەی GitHub کێشەیەک پێشکەش بکە بۆ ڕاپۆرتی هەڵە یان داواکاری تایبەتمەندی زانیاری کۆپی کراوە بۆ کلیپبۆرد ڕیزبەندی کراوە تەواو بووە دابەزاندن هەڵوەشایەوە وەرگرتنی زانیاری فایلەکە بکەرەوە دووباره‌ هه‌لكردنووكوژانه‌وه‌ هەڵە کۆپی بەستەر ڕاپۆرتی هەڵە ڕوونی ڤیدیۆ قەبارەی فایلە ڤیدیۆییەکان %1$d هەناردەکراوی قاڵب(ەکان) هاوردەکراوی %1$d قاڵب(ەکان) بەم دواییە زیاد کراوە لابردن یان نیشانەکردنی بەشەکان لە ڤیدیۆکاندا بە SponsorBlock API ئەرکی دابەزاندن هەڵوەشایەوە هەناردەکردن بۆ کلیپبۆرد لە کلیپبۆردەوە هاوردە بکە %1$d ئەرکەکانی دابەزاندن %1$d ڤیدیۆ(ەکان)، %2$d فایل(ەکان)ی دەنگی %1$d item(s) لە مێژووی دابەزاندنەکەت بۆ هەمیشە لادەبەیت؟ ================================================ FILE: app/src/main/res/values-cs/strings.xml ================================================ Uložit jako zvuk Uložit miniaturu Obecné, formát, vlastní příkaz Používáte nejnovější verzi yt-dlp Oprávnění zamítnuto Stahování dokončeno Stahování souboru selhalo Stahování „%1$s“ Získávání informací o videu selhalo Již probíhá jiná stahovací úloha Vložit adresu Nepodařilo se najít adresu ve schránce Yt-dlp verze Odstranit\? Potvrdit Zrušit Stažené soubory Verze, zpětná vazba, automatické aktualizace Zpět Verze Otevřít seznamy změn a nové verze Nejnovější vydání Zkontrolováno Poděkování Vlastní příkaz Spustit příkaz yt-dlp s vlastní šablonou Upravit Zahájeno provádění příkazu Zobrazení Tmavý motiv, dynamická barva, jazyk Tmavý motiv Podle systému Zapnuto Vypnuto Zrušit Konfigurace před stažením Upřesnit předvolby před stažením Upravit tohle stahování Chybová zpráva zkopírována do schránky Miniatura Vložit Návod k používání yt-dlp Převést formát zvuku Nepřevedeno Převést na %1$s Překódování zvukových souborů zapříčiní ztrátu zvukové kvality a zvýší velikost souboru. Kvalita videa Nespecifikován (výchozí) Preferovaný formát při dostupnosti více formátů Stáhnout Již nezobrazovat Uživatelská příručka Otevřít nastavení Přehled a správa stažených souborů, včetně videí i zvukových souborů. Stáhnout playlist Stáhnout několik videí z playlistu Stahování Oznámení o stažených souborech a průběhu stahování Spouštění vlastních příkazů… Pro stahování na pozadí nastavte v systémových nastaveních využití baterie pro tuto aplikaci na „Neomezeno“. Stahování ve více vláknech Možnosti Další nastavení Nelze nalézt URL ze sdíleného obsahu Hledání odkazu videa ze sdíleného obsahu… Zobrazit více akcí Oznámení o stahování Získávání informací o playlistu… Rozmezí playlistu Od Do Neplatný rozsah Stahování playlistu (%1$d/%2$d)… Složka zvuku Adresář pro stahování Vyberte, kam ukládat videa a zvukové soubory Uložit do podsložky Ukládat soubory do složek pojmenovaných jako příslušná pole Problém s oprávněním Složky mimo Download/ a Documents/ nejsou podporovány Ignorovat optimalizaci baterie pro tuto aplikaci ke stahování na pozadí Jazyk Složka videí Nastavení Instalace nejnovější verze yt-dlp selhala. Zkontrolujte prosím připojení k internetu. Získávání informací o videu… Navždy odstranit „%1$s“ z historie stahování? Odstranit Stahování Odkaz nemůže být prázdný Stáhnout a uložit zvuk namísto videa Uložit miniaturu videa jako soubor Nastavit jazyk Zvuk Odkaz zkopírován do schránky Otevřít odkaz Odstranit soubor Šablona příkazu Obecné Klikněte pro nainstalování nejnovější verze yt-dlp O aplikaci Otevřít repozitář na GitHubu a soubor README Video Poděkování a svobodný software Pokročílé Podrobné informace Zobrazit podrobné informace při stahování Cesta k souboru a URL budou automaticky přidány aplikací. Formát Omezit kvalitu videa, pokud je jich přítomno více Formát videa Nejlepší kvalita Preferovaný formát videa Zavřít Klepněte na „Vložit“ pro získání odkazu na video ze schránky. Po úpravě nastavení klepněte na „Stáhnout“. Před používáním se podívejte se na nastavení stahování a ujistěte se, že máte nejnovější verzi yt-dlp. Převést Výchozi Odkaz na video Neznámá chyba Stahování dokončeno. Stiskněte pro otevření. Paralelně stahovat více částí videí M3U8/MPD Oznamovat stažené soubory a postup stahování Počet vláken pro souběžné stahování DASH/HLS native videí je %d. Upřesněte rozsah videí ke stažení z playlistu „%3$s“ (od %1$d do %2$d). Nastavení baterie Seal stahuje… Překlad Pomozte přeložit tuto aplikaci na Hosted Weblate Předpona Ve frontě Dokončeno Stahuje se Zrušeno Získávání informací Otevřit soubor Restartovat Vložit titulky Vložit soubory titulků do videí, pokud jsou dostupné Nová šablona Štítek Odstranit\? Navždy ddstranit „%1$s“ z šablon příkazů? Výběr šablony Upravit a spravovat šablony příkazu Probíhá stahování… Stahování zrušeno Problém na GitHubu Vytvořit nový problém pro nahlášení chyby nebo žádost o funkci Informace zkopírovány do schránky Verze yt-dlp, upozornění, playlist Zkontrolovat aktualizace Používáte nejnovější verzi Aktualizovat Soubory cookie Rozlišení videa Stahování pomocí mobilní sítě je podle vašeho nastavení zakázáno Maximální rychlost Použít barvy z tapety v motivu aplikace Režim vícenásobného výběru Dočasné soubory lze použít k obnovení zrušených stahování. Opravdu chcete všechny tyto soubory odstranit? \n \nTyto soubory se nacházejí v %1$s Omezit maximální rychlost stahování Automaticky kontrolovat nejnovější verzi na GitHubu Nedávno přidané Importovány %1$d šablony %1$d Naplánovaných stahování Aktualizace na nejnovější verzi selhala Kategorie SponsorBlocku Vymazat dočasné soubory Odstranit všechny dočasné soubory z dočasného adresáře Zakázat historii stahování Neplatné zadání Nedostupné Exportovány %1$d šablony Omezení rychlosti Pro stahování používat soubory cookie ve formátu Netscape Soukromý režim Dynamická barva Povolit stahování médií při připojení k měřeným sítím Tento soubor již není k dispozici Chyba Exportovat do schránky Importovat ze schránky %1$d video souborů, %2$d zvukových souborů Nadobro odstranit %1$d položek z vaší historie stahování\? Odstranit nebo označit sponzorované části videí pomocí API SponsorBlock Používat aria2c jako externího správce stahování Kopírovat odkaz Kopírovat hlášení Velikost souboru videa Tmavý motiv s vysokým kontrastem Formát souboru, kvalita videa, titulky Odstraněno %1$d dočasných souborů Stahovat pomocí mobilní sítě Síť Nejnižší kvalita Upřesněte kategorie SponsorBlocku, které mají být odstraněny nebo označeny v souboru videa Uložit Omezit názvy souborů na určité znaky pro zajištění kompatibility Omezit názvy souborů Vložit metadata %.2f GB %.2f MB Zobrazit všech %1$d položek Upravit soubor Vložit metadata a miniaturu videa do zvukového souboru přechod na sestavení z GitHubu yt-dlp je výkonný nástroj pro příkazový řádek sloužící ke stahování videí. Seal zjednodušuje jeho používání díky intuitivnímu grafickému rozhraní, přednastavením pro časté příkazy a dalším dodatečným funkcím. \n \nPokročilým uživatelům nástroje yt-dlp umožňuje Seal vytvářet, ukládat a přímo spouštět vlastní šablony příkazů, stejně jako v terminálu. \n \nPři používání vlastních příkazů bude většina možností a funkcí v grafickém rozhraní vypnuta. Vyčistit archiv stahování? Začít Log Video bude rozděleno na %1$d dílů Odstranit tuto položku pro „%1$s“? Soubory cookie uložené pro tuto stránku nebudou vymazány. Kvalita zvuku Formát zvuku Název Použít cookies Exportovat do souboru Stabilní Aplikace potřebuje váš souhlas pro zasílání upozornění o stavu a postupu stahování. Výběr složky Žádná stažená média Instalujte předběžné sestavení aplikace abyste mohli nahlédnout na nové funkce a změny. \n \nTato sestavení mohou být nestabilní, pokud se tedy setkáte s nějakými problémy, prosím neváhejte a poskytněte nám zpětnou vazbu pro pomoc s vylepšováním aplikace do budoucna. Upravit vlastní zkratky, které mohou být použity pro vytváření šablon pro příkazy. Video (bez zvuku) Rozdělit video Upřednostnit formáty MP4(H.264) pro sdílení v ostatních aplikacích Stáhnout videa z URL Příkazy Zakázat náhled Stahování používající tuto funkci budou předány FFmpeg ke stažení vybraných částí videa, tato funkce je stále experimentální a stříhání videa nebude naprosto přesné, některé formáty tuto funkci nepodporují a můžete se setkat s pomalejším stahováním. Některé možnosti jsou nedostupné při používání vlastních příkazů Navždy odstranit %1$s v archivním souboru? Nezobrazovat náhledy při stahování Vybrat formát souboru před zahájením stahování Konec Upravit „%1$s“ Stahovat automaticky generované titulky %1$d vybráno Doporučené Vyčistit Neznámé Stahovat titulky Zjistit více Přejmenovat Převést titulky Upřednostnit formáty AV1, VP9 nebo H.265 pro sledování v kompatibilních aplikacích Zkopírovat log Typ stahování Aplikovat Starší verze Vytvářejte klipy z videa na stránce s výběrem formátu Jejda! Chybička se vloudila Neomezený Upravit zkratky Jak to funguje? Rozumím Adresář pro vlastní příkazy Výběr formátu Upřesněte výstupní adresář při používání vlastních příkazů Použít třídění formátů Zpětná vazba Omezení rychlosti, stahování, soubory cookie Beta Smazat všechny cookies Přednastavení sekunda Klikněte pro nastavení adresáře Kanál aktualizací Seal bude vždy zdarma a open source pro každého. Pokud se vám aplikace líbí, prosím zvažte sponzorování na GitHubu! Oříznout obrázek Titulky mohou mít špatné časování při odstranění SponsorBlock segmentů. Začít Moc vám děkujeme! Podpořte tuto aplikaci sponzorováním na GitHubu Povolit experimentální funkce? Běžící úlohy Hlavička s User-Agentem Ukázat log Zahodit Složka na SD kartě Preferovaný formát zvuku Nová úloha pro stahování Zkopírovat a odejít Ukládat stažené soubory do skrytého adresáře Vybrat vše Třídit formáty s možností -S v nástroji yt-dlp Automatické aktualizace Sponzor Automatické titulky Vyžadované Pro účel vložení souborů titulků budou videa převedena do kontejneru mkv. Pro přehrávání videí se soubory titulků můžete použít například VLC Media Player nebo jiné kompatibilní aplikace. Většina platforem pro streamování videa poskytuje zvuk a video odděleně, můžete vybrat a sloučit zvukové a video formáty do jednoho videa. Žádná úloha s vlastními příkazy Povolit automatické aktualizace Nejnižší bitrate Upřednostňovaný formát Náhled Konvertovat titulky na jiný formát Ukázkový text pro název videa Použít vlastní příkaz Šablona výstupu Upřesněte šablonu pro názvy souborů ve výstupu Jazyk titulků Omezit datový tok zvuku, když je dostupných několik různých kvalit Zakázané Soukromý adresář Zakázat Dobře Automaticky Zkratky Vyberte videa ke stažení z playlistu „%1$s“ %d položka %d položky %d položek Ukázkový text pro autora videa Rozšířit Kanál na Telegramu Funkce není dostupná Uložit dočasné soubory v interním adresáři Automatické aktualizace nejsou dostupné pro sestavení ze služby %1$s. Pokud nemáte na zařízení nainstalovanou službu %1$s nebo pokud byste chtěli vidět nadcházející nové funkce v Sealu, zvažte prosím %2$s. Stahování z některých stránek vyžaduje autentifikační informace z účtu. Klepněte na „Vygenerovat nové cookies“, zadejte adresu stránky a poté se přihlaste s vaším účtem v okně prohlížeče, aplikace se postará o vygenerování za vás. Použít proxy pro připojení k internetu Vygenerovat nové cookies Kvalita Klikněte pro otevření webové stránky pro generování nových cookies: Prostor Matrix Titulky Zpráva od vývojáře Importovat Klip z videa Sdílet Přidat Soukromí Zaznamená ID stažených videí v archivu pro zamezení duplikátům Vlastní Aktualizovat yt-dlp Rychlé stáhnutí Oříznout vložený obrázek do čtverce Archiv stahování Jazyky, vložené titulky, automatické titulky Navždy odstranit %1$s z šablon pro příkazy? Povolit upozornění? Proxy Sponzoři Chcete navždy smazat všechny cookies uložené v aplikaci? minuta Třídění formátů Vaše stahování budou uložena jako: Webová stránka Název playlistu Nepovolit Vynutit IPv4 Povolit pouze jednou Povolit vždy Povolit stahování přes datové připojení? Sloučit několik zvukových streamů Povolit spojení několika zvukových streamů do jednoho souboru Ponechat soubor s titulky Nastavení systému Provádět veškerá připojení přes IPv4 Hledat ve stažených souborech Hledat Aktualizovat jazyky titulků? Automaticky přeložené titulky Automaticky přeložené titulky pro všechny jazyky budou k dispozici ve Stažených. Tyto titulky mohou být nepřesné a obtížné na porozumění. Zapamatovat pro další stahování Použít předchozí výběr Žádný Jazyk titulků ke stažení ve výběru Auto formátu, oddělené čárkami. Resetovat Vyhledat v titulcích Ne, díky Následující jazyky budou přidány do vašich preferencí pro budoucí stahování: Video bylo staženo. Pokud jste tohle nechtěli, prosím, zkontrolujte váš archiv stažených souborů. Soubor Exportovat Importovat Úplná záloha Exportování %1$s z historie stahování. Stažené soubory a preference nebudou zálohovány. Typ zálohy Exportovat do Schránka Importovat z Exportovat historii stahování? Importovat historii stahování? Stažené soubory nebudou importovány. Budete je muset znovu stáhnout manuálně Historie stahování Importováno %1$s do historie stahování Stáhnout znovu Převést kontejner videa Převést videa do kontejneru MKV pro lepší kompatibilitu Celkem %1$d souborů cookie z %2$d webových stránek Každý den Každý týden Každý měsíc %d audio %d audia %d audií Úloha přidána do fronty %d video %d videa %d videí Playlist Všechny jazyky Předvolba Pokračovat Upřednostnit %1$s Vyberte si z formátů, titulků a dále si přizpůsobte aplikaci Automaticky stáhnout s použitím vašich preferencí formátů Upravit předvolbu Stáhnout nejlepší dostupný formát Zde najdete svá stahování Pro spuštění stahování klepněte na tlačítko stahování nebo sdílejte video do této aplikace Stažené Vše Vyberte z %1$d odkazů Fronta stahování Zobrazit navigační lištu Pokračovat Smazat Informace o médiu Řešení problémů Sledování problémů Opravte běžné chyby a zjistěte známé problémy Našli jste chybu? Před jejím nahlášením prosím prohledejte náš systém sledování chyb. Mnoho problémů zde již bylo řešeno a zdokumentováno. Uložené odkazy Přidat nový odkaz Přidat do %1$s ================================================ FILE: app/src/main/res/values-da/strings.xml ================================================ Lagre som miniaturbilde Indstillinger Generelt, format, tilpasset kommando Last ned og gem lyd, i stedet for video Bruger nyeste version af yt-dlp Kunne ikke installere den seneste yt-dlp-version. Kontroller at du har forbindelse til Internet. Henter videoinfo … Nedlastning fuldført Kunne ikke laste ned fil Last ned «%1$s» Kunne ikke hente videoinfo Generelt Visningssprog Vælg visningssprog En eksisterende nedlastningsopgave kører allerede Lim ind URL fra udklipsholderen Kunne ikke matche URL\'en i udklipsholderen Yt-dlp-version Videomappe Lagre som lyd Link til indhold kan ikke være tom Last ned Gem video miniaturbilde som en fil Tilgang ikke indvilget Klik for at installere den seneste yt-dlp-versionen Fjern\? Begynd udføring af kommando Visning Konverter System Afbryd Foretrukket videoformat Format Videoformat Last ned Luk Ikke vis igen Åbne indstillingerne Flere indstillinger Slut Ugyldigt indeksområde Download spilleliste (%1$d/%2$d), tryk for at stoppe. Seal laster ned … Ukendt feil Overset Bekræft Afbryd Nedlastninger Lyd Åbn link Fjern Om Version, udgivelser, bidragsydere Tilbage Version Nyeste udgivelse Video Bidragsydere Bidragsydere og fri programvare Tilpasset kommando Kør yt-dlp-kommando med tilpasset mal Kommandomal Rediger Fjern «%1$s» fra din nedlastningshistorik for godt\? Lænke kopieret til udklipsholderen Slet Se efter ændringslister og nye versioner Tjek GitHub depotet og README Tjekket Avancered Af Lim ind Brugsreference for yt-dlp Konverter til %1$s Omkodning af lydfiler vil forårsage tab i lydkvalitet og øge filstørrelsen. Videokvalitet Ikke angivet (forvalg) Valgt format når flere forefindes Konfigurer før download Mørk dragt, dynamiske farver, sprog Mørk dragt Detaljeret uddata Udskriv detaljerede meddelelser under download Konfigurer præferencer før nedlastning Juster denne download Fejlrapport kopieret til udklipsholderen miniaturbilde Uddatasti og URL vil blive tillagt af programmet. Konverter lydformat Ukonverteret Bedste kvalitet (forvalg) Begræns videokvaliteten, når flere er tilgængelige Brugervejledning Last ned spilleliste Forvalg Last ned Download med flere tråde Download flere dele af M3U8/MPD-videoer parallelt Alternativer Vis flere handlinger Valg af spilleliste Start Vælg hvor videoer og lydfiler lagres Lydmappe Downloadmappe Lagre i undermappe Lagre filer i mapper navngivet som respektive internetsider Problem med lagringstilgang Klik «Lim ind» for at hente videolænke fra udklipsholderen. Klik så «Last ned» når du har justeret dens indstillinger. Tjek og kontroller nedlastninger i programmet, inkluderet videoer og lydfiler. Kig på nedlastningsindstillingerne og sørg for at du har nyeste version af yt-dlp før du tar det i brug. Last ned flere videoer fra en spilleliste Tilby meddelelse om nedlastede filer og fremdrift Videolænke Download fuldført. Klik for at åbne. Kører tilpassede kommandoer … Indstil batteriforbrug for dette program til «Ubegrænset» i systemindstillingerne for at laste ned i baggrunden. %d tråd(er) bil blive brugt samtidig for nedlastning af DASH/HLS native -video. Kan ikke matche URL fra delt indhold Læser videolænke fra delt indhold … Tilby meddelelse om filer og fremdrift Angiv hvilke videoer, der skal downloades fra afspilningslisten \"%3$s\" (fra %1$d til %2$d). Mapper udenfor Download/ og Documents/ understøttes ikke Bistå oversættelsen af programmet på Hosted Weblate Ignorer batterioptimering så dette program kan laste ned i baggrunden Henter spillelisteinfo … Meddelelse om nedlastning Konfiguration af batteri Path template Foretrukket lydformat Ubegrænset Laveste bitrate Lydkvalitet Begræns lydbitraten, når der er flere tilgængelige kvaliteter Formatsortering Sortering af formater med yt-dlp\'s -S indstilling Importer Titel Omdøb Sekund Minut Ryd alle cookies Vil du slette alle de cookies, der er gemt i appen\? %.2f M %.2f G Download i gang, tryk for at annullere. Undertekster kan være forkert timet, når SponsorBlock-segmenter fjernes. Del Stable Preview Installer pre-release builds for at få vist nye funktioner og ændringer. \n \nDer vil være en vis ustabilitet i disse versioner, så tøv ikke med at give os feedback, hvis du oplever problemer for at hjælpe os med at forbedre appen for fremtiden. Aktiver automatisk opdatering Annulleret Henter info Angiv SponsorBlock-kategorier, der skal fjernes fra videofilen Brug aria2c som ekstern downloader Log Cookies Eksempeltekst til video creator Rediger genveje Download over mobildata Tillad download af medier over forbrugsafregnet netværk Download med mobilnetværk er deaktiveret i henhold til dine indstillinger Netværk Hastighedsgrænse Begræns den maksimale downloadhastighed Kassér Anvend Klip video Start Slut Integrer undertekster Integrer undertekster i videoer, hvis de er tilgængelige %1$d Downloads Kunne ikke opdatere til nyeste version Den aktuelle version er opdateret Kopier link Vælg alle Importerede %1$d skabelon(er) Download annulleret Brug brugerdefineret kommando Gem downloads i en skjult mappe Importer fra udklipsholder Brug Netscape-formaterede cookies til downloads Afsluttet Foreslået Privat tilstand Indsend problem til fejlrapport eller funktionsanmodning Videoopløsning Hvordan virker det\? Info kopieret til udklipsholder Eksporter til udklipsholder Multiselect-tilstand Deaktiver downloadhistorik Anvend farver fra wallpapers til app-temaet Dynamisk farve Beskær indlejret billede til firkant Vælg videoer, der skal downloades fra afspilningslisten \"%1$s\" %1$d valgt I kø Nyligt Tilføjet %1$d video(er), %2$d lydfil(er) Fjern %1$d element(er) fra din downloadhistorik for altid\? Ny skabelon Etiket Fjern\? Fjern \"%1$s\" fra kommandoskabeloner for altid\? Skabelonvalg Rediger og administrer kommandoskabeloner Downloader Åbn fil Video filstørrelse Eksporterede %1$d skabelon(er) Søg efter opdateringer Slettede %1$d midlertidig(e) fil(er) Midlertidige filer kan bruges til at genoptage annullerede downloads. Bekræft at du vil slette disse filer\? Generer nye cookies Vælg det format, der skal downloades, før du starter download Brug Cookies Fjern cookies for \"%1$s\"? Nogle muligheder er utilgængelige, når brugerdefineret kommando bruges Download fra nogle websteder kræver kontogodkendelse. Klik på \"Generer nye cookies\", indtast webadressen på hjemmesiden og log derefter ind med din konto på browsersiden; appen genererer den nye cookie for dig. Telegram-kanal De fleste videostreamingplatforme leverer lyd og video separat, du kan vælge og flette et lydformat med et videoformat til en enkelt video. GitHub-problem Genstart Fejlrapport Fjern segmenter fra videoer med SponsorBlock API Fejl SponsorBlock-kategorier Søg automatisk efter den nyeste version på GitHub Opdater Ryd midlertidige filer Slet alle midlertidige filer fra download-mappen Filen er ikke længere tilgængelig Mørkt tema med høj kontrast Ugyldigt input Laveste kvalitet Utilgængelig Filformat, videokvalitet, undertekster Yt-dlp version, notifikation, afspilningsliste Deaktiver forhåndsvisning Deaktiver forhåndsvisning på downloads Privatliv Privat mappe Beskær grafik Video (ingen lyd) Formatvalg SD-kort mappe Automatiske undertekster Download autogenererede undertekster Hurtig download Eksempeltekst på videotitel Undertekst Download undertekster Undertekstsprog Sprog, integrer undertekster, autoundertekster Kopiér log Ryd Tilføj Genveje Rediger de brugerdefinerede genveje, som kan bruges til at lave kommandoskabeloner. Kørende opgaver Vis log Ved indlejring af undertekster vil videoer blive remuxet til mkv-container. Gem midlertidige filer i den interne mappe ================================================ FILE: app/src/main/res/values-de/strings.xml ================================================ Miniaturansicht speichern Der Link darf nicht leer sein Audio herunterladen und speichern, statt Video Vorschaubild des Videos als Datei speichern Verwendung der neuesten Version von yt-dlp Videoinformationen werden abgerufen… Keine Berechtigung Download abgeschlossen Datei konnte nicht heruntergeladen werden „%1$s“ herunterladen Videoinformationen konnten nicht abgerufen werden Allgemein Anzeigesprache festlegen Eine bestehende Download-Aufgabe wird bereits ausgeführt Link einfügen Entfernen\? „%1$s“ für immer aus deinem Downloadverlauf entfernen\? Bestätigen Abbrechen Downloads Audio Link in die Zwischenablage kopiert Link öffnen Entfernen Version, Rückmeldung, Automatisches Update Zurück Version Siehe Änderungsprotokolle und neue Versionen Neueste Version Prüfe das GitHub-Repository und die README Video Geprüft Credits Credits und freie Software Anzeige Dunkles Farbschema Vor dem Herunterladen konfigurieren Einstellungen vor dem Herunterladen konfigurieren Diesen Download anpassen Fehlerbericht in die Zwischenablage kopiert Vorschaubild Einfügen Herunterladen Standard Videolink Download beendet. Zum Öffnen antippen. Wiedergabeliste herunterladen Mehrere Videos aus einer Wiedergabeliste herunterladen Benutzerhandbuch Einstellungen öffnen Herunterladen Bevorzugtes Videoformat Videoformat Videoqualität Beste Qualität Videoverzeichnis Einstellungen Die neueste Version von yt-dlp konnte nicht installiert werden. Bitte stelle sicher, dass du mit dem Internet verbunden bist. Angezeigte Sprache Link in der Zwischenablage konnte nicht gefunden werden Yt-dlp-Version Klicke hier, um die neueste Version von yt-dlp zu installieren Optionen Als Audio speichern Allgemein, Format, benutzerdefinierter Befehl Über Herunterladen Datei Löschen Dunkles Farbschema, dynamische Farben, Sprachen Abbrechen Benutzerdefinierter Befehl Nicht mehr anzeigen Bearbeiten System Ein Aus Schließen Bevorzugtes Format, wenn mehrere vorhanden sind Nicht angegeben (Standard) Einschränken der Videoqualität, wenn mehrere vorhanden sind yt-dlp-Befehl mit benutzerdefinierter Vorlage ausführen Befehlsvorlage Befehl ausführen Erweiterte Detaillierte Ausgabe Yt-dlp-Verwendungshinweise In %1$s konvertieren Audioformat konvertieren Multithread-Download Mehrere Teile von M3U8/MPD-Videos parallel herunterladen Weitere Aktionen anzeigen Benachrichtigung beim Herunterladen Benachrichtigung über heruntergeladene Dateien und den Fortschritt Wiedergabeliste-Informationen werden abgerufen … Wiedergabeliste-Auswahl Wähle den Speicherort für Videos und Audiodateien In Unterordner speichern Dateien in Ordnern mit den Namen der jeweiligen Felder speichern Problem mit der Speicherberechtigung Akkukonfiguration Akku-Optimierung für diese App ignorieren, um im Hintergrund herunterzuladen Seal lädt herunter … Benachrichtigung über heruntergeladene Dateien und den Fortschritt Zusätzliche Einstellungen Herunterladen der Wiedergabeliste (%1$d/%2$d)… Format Detaillierte Meldungen beim Herunterladen drucken Nicht konvertieren Audioordner Bitte setze die Akkunutzung dieser App in den Systemeinstellungen auf „Uneingeschränkt“, um im Hintergrund herunterzuladen. URL aus geteilten Inhalt kann nicht zugeordnet werden Downloadordner Unbekannter Fehler Benutzerdefinierte Befehlen ausführen … Übersetzen Hilf bei der Übersetzung dieser App auf Weblate Ausgabepfad und URL werden von der App hinzugefügt. Konvertieren Klicke auf „Einfügen“, um den Videolink aus der Zwischenablage zu holen. Klicke dann auf „Herunterladen“, nachdem du die Einstellungen angepasst hast. Überprüfe und verwalte In-App-Downloads, einschließlich Videos und Audiodateien. Schaue dir die Download-Einstellungen an und stelle sicher, dass du die neueste Version von yt-dlp hast, bevor du es verwendest. %d Thread(s) würden für das gleichzeitige Herunterladen von nativen DASH/HLS-Videos verwendet werden. Videolink von geteilten Inhalten lesen… Gib den Bereich der Videos an, die aus der Wiedergabeliste „%3$s“ heruntergeladen werden sollen (von %1$d bis %2$d). Ende Ungültiger Indexbereich Ordner außerhalb von Download/ und Dokumente/ werden nicht unterstützt Die Neukodierung von Audiodateien führt zu einem Verlust an Audioqualität und einer Vergrößerung der Datei. Start Ordnervorlage Untertitel einbetten Einbetten von weichen Untertiteln in Videos, sofern verfügbar Neue Vorlage Label Entfernen\? „%1$s“ endgültig aus den Befehlsvorlagen entfernen\? Vorlagenauswahl Befehlsvorlagen bearbeiten und verwalten Download läuft… Download-Aufgabe wurde abgebrochen GitHub-Problem Informationen in die Zwischenablage kopiert Einreichen eines Fehlerberichts oder einer Funktionsanfrage Eingereiht Fertig Wird heruntergeladen Abgebrochen Informationen abrufen Datei öffnen Neustarten Fehler Link kopieren Bericht kopieren Videoauflösung Größe der Videodatei In Zwischenablage exportieren Aus Zwischenablage importieren Exportierte %1$d Vorlage(n) Importierte %1$d Vorlage(n) Kürzlich hinzugefügt %1$d Video(s), %2$d Audio-Datei(en) %1$d für immer aus deinem Downloadverlauf entfernen\? %1$d Download-Aufgaben Nach Aktualisierungen suchen Automatisch nach der neuesten Version auf GitHub suchen Die aktuelle Version ist auf dem neuesten Stand Aktualisieren Aria2c als externen Downloader verwenden Aktualisierung auf die neueste Version fehlgeschlagen Segmente aus Videos mit SponsorBlock-API entfernen oder markieren Gib die SponsorBlock-Kategorien an, die aus der Videodatei entfernt oder markiert werden sollen SponsorBlock-Kategorien Netscape-formatierte Cookies für Downloads verwenden Temporäre Dateien löschen Alle temporären Dateien aus dem temporären Ordner löschen Temporäre Dateien können verwendet werden, um abgebrochene Downloads wieder aufzunehmen. Bist du sicher, dass du alle diese Dateien löschen willst? \n \nDu kannst diese Dateien zugreifen unter %1$s %1$d temporäre Datei(en) gelöscht Mehrfachauswahl-Modus Download-Verlauf deaktivieren Inkognito Dynamische Farbe Farben von Hintergrundbildern auf das Farbschema anwenden Herunterladen über Mobilfunk Herunterladen von Medien erlauben, wenn du mit gebührenpflichtigen Netzwerken verbunden bist Das Herunterladen über das Mobilfunknetz ist gemäß deinen Einstellungen deaktiviert Diese Datei ist nicht mehr verfügbar Netzwerk Geschwindigkeitsbegrenzung Maximale Downloadgeschwindigkeit limitieren Maximale Geschwindigkeit Dunkles Farbschema mit hohem Kontrast Ungültige Eingabe Niedrigste Qualität Dateiformat, Videoqualität, Untertitel Vorschau deaktivieren Nicht verfügbar Datenschutz Benutzerdefinierten Befehl verwenden Yt-dlp Version, Benachrichtigung, Wiedergabeliste Ratenbegrenzung, Downloader, Cookies Keine Anzeige von Vorschaubildern während des Downloads Privater Ordner Downloads in einem versteckten Ordner speichern Bild zuschneiden Eingebettetes Bild auf ein Quadrat zuschneiden Alle auswählen %1$d ausgewählt Wähle die Videos zum Herunterladen aus der Wiedergabeliste „%1$s“ Video (kein Audio) Vorgeschlagen Auswahl des Formats Wähle das herunterzuladende Format, bevor du den Download startest Neue Cookies generieren Cookies verwenden Diesen Eintrag für „%1$s“ entfernen? Bitte beachte, dass gespeicherte Cookies für diese Seite nicht gelöscht werden. Einige Optionen sind bei Verwendung des benutzerdefinierten Befehls nicht verfügbar Wie funktioniert das\? Für das Herunterladen von einigen Webseiten sind Informationen zur Kontoauthentifizierung erforderlich. Klicke auf „Neue Cookies generieren“, gib die URL der Webseite ein und melde dich dann mit deinem Konto auf der Browserseite an, damit die App sie für dich generieren kann. Telegram-Kanal Cookies Matrix-Raum Die meisten Videostreaming-Plattformen liefern Audio und Video getrennt. Du kannst ein reines Audioformat auswählen und mit einem reinen Videoformat zu einem einzigen Video zusammenfügen. Individuelle Verknüpfungen bearbeiten, die zur Erstellung von Befehlsvorlagen verwendet werden können. Laufende Aufgaben Automatische Untertitel Schnelles Herunterladen Videotitel Beispieltext Videoersteller Beispieltext Untertitel Untertitel herunterladen Sprachen, Untertitel einbetten, automatische Untertitel Protokoll kopieren Löschen Verknüpfungen bearbeiten Protokoll anzeigen Protokoll Verknüpfungen SD-Kartenordner Automatisch generierte Untertitel herunterladen Untertitelsprachen Hinzufügen Untertitel können beim Entfernen von SponsorBlock-Segmenten falsch synchronisiert werden. Zum Einbetten von Untertiteln werden die Videos in einen mkv-Container zusammengefügt. Du kannst den VLC Media Player oder andere kompatible Apps verwenden, um Videos mit eingebetteten weichen Untertiteln anzusehen. %.2f GB %.2f MB Vorschau Automatische Aktualisierung Update-Kanal Teilen Installiere Vorabversionen, um eine Vorschau der neuen Funktionen und Änderungen erhalten. \n \nEs wird einige Instabilitäten in diesen Versionen geben, also zögere bitte nicht, uns Feedback zu geben, wenn du Probleme hast, um uns zu helfen, die App für die Zukunft zu verbessern. Automatische Aktualisierung aktivieren Stabil Ende Verwerfen Anwenden Video schneiden Anfang Niedrigste Bitrate Audioqualität Audio-Bitrate begrenzen, wenn mehrere Qualitäten vorhanden sind Formatsortierung Importieren Titel Sekunde Minute Alle in der App gespeicherten Cookies für immer löschen\? Bevorzugtes Audioformat Unbegrenzt Sortieren von Formaten mit der Option -S von yt-dlp Umbenennen Alle Cookies löschen Temporäre Dateien im internen Ordner speichern Sponsor Unterstütze diese App durch Sponsoring auf GitHub Seal wird immer frei und kostenlos für alle sein. Wenn du es magst, bitte denke bitte darüber nach, mich auf GitHub zu sponsern! Rückmeldung Sponsoren Keine heruntergeladenen Medien Beta Experimentelle Funktion aktivieren\? Downloads, die diese Funktion nutzen, werden an FFmpeg delegiert, um ausgewählte Abschnitte des Videos herunterzuladen. Diese Funktion ist noch experimentell und der Schnitt wird nicht ganz genau sein, nicht alle Formate unterstützen diese Funktion und es kann sein, dass du langsamere Downloadgeschwindigkeiten hast. Audioformat Videoclips auf der Formatauswahlseite erstellen Okay Funktion nicht verfügbar Automatische Updates sind für %1$s Builds nicht verfügbar. Wenn du %1$s nicht auf deinem Gerät installiert hast, oder eine Vorschau kommender Funktionen in Seal möchtest, ziehe bitte %2$s in Erwägung. Nachricht vom Entwickler Vielen Dank! Wechseln zu GitHub-Builds Keine benutzerdefinierten Befehlsaufgaben Verstanden Videos von der URL herunterladen Untertitel konvertieren Konvertiert die Untertitel in ein anderes Format Video aufteilen Video wird in %1$d Kapitel aufgeteilt Hoppla! Etwas ist schief gelaufen Kopieren und beenden Starten „%1$s“ bearbeiten Neue Download-Aufgabe Erweitern yt-dlp aktualisieren Proxy Proxy für Internetverbindungen verwenden Deaktiviert MP4(H.264)-Formate für die Weitergabe an andere Apps bevorzugen Benachrichtigungen aktivieren\? Qualität Die App benötigt deine Erlaubnis, um Benachrichtigungen über den Status und den Downloadfortschritt anzuzeigen. Deaktivieren AV1-, VP9- oder H.265-Formate für die Wiedergabe in kompatiblen Apps bevorzugen Export in die Datei Ordnerauswahl Befehle Unbekannt Mehr erfahren Art des Herunterladens Benutzerdefiniertes Befehlsverzeichnis Gib das Ausgabeverzeichnis an, wenn du benutzerdefinierte Befehle verwendest Tippen, um Verzeichnis einzurichten User-Agent Header Format Präferenz Automatisch %d Element %d Elemente Tippen, um die Webseite für die Erstellung neuer Cookies zu öffnen: Benutzerdefiniert \"%1$s\" endgültig aus den Befehlsvorlagen entfernen\? yt-dlp ist ein mächtiges Werkzeug um Videos herunterzuladen. Seal macht es einfacher yt-dlp zu nutzten, indem es eine intuitive Benutzeroberfläche, Voreinstellungen für häufige Befehle und andere Funktionen bereitstellt. \n \nFür erweiterte Nutzung von yt-dlp erlaubt Seal es dir benutzerdefinierte Befehlsvorlagen zu erstellen, speichern und auszuführen, genau wie in einer Befehlszeile. \n \nWenn benutzerdefinierte Befehle genutzt werden sind die meisten Optionen und Funktionen in der Benutzeroberfläche deaktiviert. Ausgabemuster Archiv löschen? Speichern Ihre Downloads werden gespeichert als: Soll %1$s aus der Archivdatei entfernt werden? Webseite Metadaten einbetten Format-Sortierung verwenden Voreinstellungen Dateinamen auf bestimmte Zeichen begrenzen, um die Kompatibilität zu gewährleisten Erforderlich Alle %1$d anzeigen Vorlage für Ausgabedateinamen angeben Dateinamen einschränken Datei bearbeiten Erfasse heruntergeladene Video-IDs in einem Archiv, um doppelte Downloads zu vermeiden Metadaten und Video-Thumbnail in die Audiodatei einbetten Titel der Playliste Archiv herunterladen IPv4 erzwingen Legacy Systemeinstellungen Nutze für alle Verbindungen nur IPv4 Einmalig erlauben Immer erlauben Nicht erlauben Herunterladen über Mobilfunknetz zulassen? Zusammenführung von mehreren Audiospuren in eine einzelne Datei zulassen Untertiteldateien behalten Mehrere Audiospuren zusammenfügen In Downloads suchen Suchen Sprachen der herunterzuladenen Untertitel in automatischer Formatauswahl, kommagetrennt aufgelistet. Für den nächsten Download speichern Aussehen & Gefühl Vorherige Auswahl übernehmen Nichts Automatisch übersetzte Untertitel Automatisch übersetzte Untertitel für alle Sprachen werden in den Downloads verfügbar sein. Diese Untertitel können ungenau und schwer verständlich sein. Zurücksetzen Nein danke Suche in Untertiteln Sprache der Untertitel aktualisieren? Die folgenden Sprachen werden zu deiner Präferenz für zukünftige Downloads hinzugefügt: Dieses Video wurde heruntergeladen. Wenn du dieses Verhalten nicht erwartet hast, überprüfe dein Download Archiv. Backup Typ Datei Vollständiges Backup Zwischenablage Downloadhistorie exportieren? Exportieren Importieren Importieren von Downloadhistorie importieren? Heruntergeladene Dateien werden nicht importiert. Du musst die manuell erneut herunterladen Exportiere %1$s vom Download-Verlauf. Heruntergeladen Dateien und einstellen werden nicht gesichert. Download-Verlauf %1$s in Download-Verlauf importiert Erneut herunterladen Exportieren als Interface & Bedienung Remux-Videocontainer Remuxen von Videos in MKV-Container für bessere Kompatibilität %1$d Cookies von insgesamt %2$d Websites Jeden Tag Jede Woche Jeder Monat Alle Sprachen Playlist Weiter Voreinstellung Wähle aus Formaten und Untertiteln und passe sie weiter an Automatischer Download unter Verwendung deiner Formateinstellungen Voreinstellung bearbeiten Bestes verfügbare Format herunterladen Aufgabe zur Warteschlange hinzugefügt %1$s bevorzugen Hier findest du deine Downloads Heruntergeladen %d Video %d Videos Tippe auf den Download Button oder teile einen Video-Link zu dieser App, um einen Download zu starten Alle Wähle aus %1$d Links %d Audio %d Audios Problembehandlung Problemverfolgung Behebung häufiger Fehler und Überprüfung auf bekannte Probleme Auf einen Fehler gestoßen? Bevor du ein neues Problem meldest, durchsuchen bitte unseren Issue Tracker. Viele häufige Probleme wurden dort bereits behandelt und dokumentiert. Warteschlange herunterladen Navigationsschublade anzeigen Fortsetzen Löschen Medien Info ================================================ FILE: app/src/main/res/values-el/strings.xml ================================================ Αποθήκευση Εξωφύλλου Ρυθμίσεις Γενικά, μορφή αρχείου, διαφορετική εντολή Ο σύνδεσμος δεν μπορεί να είναι κενός Λήψη και αποθήκευση ήχου, αντί για βίντεο Αποθήκευση εξωφύλλου βίντεο ως αρχείο Η άδεια απορρίφθηκε Επιτυχής λήψη Αποτυχία λήψης Λήψη \"%1$s\" Γενικές ρυθμίσεις Γλώσσα Μια υπάρχουσα λήψης εκτελείται ήδη Επικόλληση συνδέσμου Έκδοση yt-dlp Αναβάθμιση yt-dlp Φάκελος Βίντεο Αποθήκευση ως Ήχο Λήψη Ρυθμίσεις γλώσσας Μη δυνατός σύνδεσμος από πρόχειρο Χρήση της πιο πρόσφατης έκδοσης yt-dlp Αποτυχία εγκατάστασης καινούργιας έκδοσης yt-dlp, βεβαιωθείται πως είστε συνδεδεμένοι στο δίκτυο. Κατάργηση \"%1$s\" από το ιστορικό λήψεων μόνιμα ; Επιβεβαίωση Ακύρωση Αφαίρεση Έκδοση Πιστώσεις Πιστώσεις και λογισμικό libre Αποτυχία λήψη πληροφοριών βίντεο Λήψη πληροφοριών βίντεο… Πατήστε για να εγκαταστήσετε την νέα έκδοση yt-dlp Αφαίρεση ; Λήψεις Ήχος Ο υπερσύνδεσμος αντιγράφτηκε στο πρόχειρο Άνοιγμα συνδέσμου Διαγραφή αρχείου Σχετικά με Έκδοση, σχόλια, αυτόματη ενημέρωση Πίσω Προβολή αλλαγών και νέων εκδόσεων Νεότερη έκδοση Προβολή του κώδηκα Github και του README Βίντεο Ελεχγμένο Προσαρμοσμένη εντολή Επιτρέψτε μία φορά Επιτρέπετε τη λήψη με κινητό; Το βίντεο έχει μεταφορτωθεί. Εάν αυτή δεν είναι η αναμενόμενη συμπεριφορά, παρακαλούμε ελέγξτε το αρχείο λήψης. Δοχείο βίντεο Remux Remux βίντεο σε δοχείο MKV για καλύτερη συμβατότητα Εκτέλεση της εντολής yt-dlp με προσαρμοσμένο πρότυπο Πρότυπο εντολών Επεξεργασία Έναρξη εκτέλεσης εντολής Προχωρημένο Λεπτομερής έξοδος Εκτύπωση λεπτομερών μηνυμάτων κατά τη λήψη Εμφάνιση Σκοτεινό θέμα, δυναμικό χρώμα, γλώσσες Σκοτεινό θέμα Σύστημα Στο Ακύρωση Ρυθμίστε αυτή τη λήψη Αντιγραφή αναφοράς σφάλματος στο πρόχειρο Μικρογραφία Επικόλληση Αναφορές χρήσης Yt-dlp Μορφή Ποιότητα βίντεο Καλύτερη ποιότητα Δεν ορίζεται (προεπιλογή) Προτιμώμενη μορφή όταν παρέχονται πολλαπλές Άνοιγμα ρυθμίσεων Επιλογές Πρόσθετες ρυθμίσεις Αδυναμία αντιστοίχισης URL από κοινόχρηστο περιεχόμενο Ανάγνωση συνδέσμου βίντεο από κοινόχρηστο περιεχόμενο… Εμφάνιση περισσότερων ενεργειών Λήψη ειδοποίησης Ειδοποίηση για τη λήψη αρχείων και την πρόοδο Λήψη πληροφοριών λίστας αναπαραγωγής… Καθορίστε το εύρος των βίντεο για λήψη από τη λίστα αναπαραγωγής \"%3$s\" (από %1$d έως %2$d). Έναρξη Τέλος Μη έγκυρο εύρος δεικτών Λήψη λίστας αναπαραγωγής (%1$d/%2$d)… Φάκελος ήχου Λήψη καταλόγου Επιλέξτε πού θα αποθηκεύσετε τα βίντεο και τα αρχεία ήχου Αποθήκευση σε υποκατάλογο Αποθηκεύστε αρχεία σε φακέλους με το όνομα των αντίστοιχων πεδίων Θέμα άδειας αποθήκευσης Διαμόρφωση μπαταρίας Αγνοήστε τη βελτιστοποίηση της μπαταρίας για αυτή την εφαρμογή για λήψη στο παρασκήνιο Ενσωματώστε τους υποτίτλους που παρέχονται στα βίντεο, εάν είναι διαθέσιμοι Νέο πρότυπο Ετικέτα Αφαιρέστε οριστικά το \"%1$s\" από τα πρότυπα εντολών; Επεξεργασία και διαχείριση προτύπων εντολών Λήψη σε εξέλιξη… Λήψη εργασίας που ακυρώθηκε Θέμα GitHub Υποβάλετε ένα θέμα για αναφορά σφάλματος ή αίτημα λειτουργίας Αντιγραφή πληροφοριών στο πρόχειρο Σε αναμονή Ολοκληρώθηκε Ακυρώθηκε Λήψη Λήψη πληροφοριών Άνοιγμα αρχείου Επανεκκίνηση Σφάλμα Αντιγραφή συνδέσμου Αντιγραφή αναφοράς Ανάλυση βίντεο Μέγεθος αρχείου βίντεο Εξαγωγή στο πρόχειρο Εισαγωγή από το πρόχειρο Εξαγωγή %1$d προτύπου(-ων) Εισαγόμενα πρότυπα %1$d %1$d Εργασίες λήψης Πρόσφατα προστέθηκε %1$d βίντεο, %2$d αρχεία ήχου Να αφαιρέσετε οριστικά το/τα %1$d στοιχείο(α) από το ιστορικό λήψεων; Αφαίρεση ή επισήμανση τμημάτων σε βίντεο με το API του SponsorBlock Καθορίστε τις κατηγορίες SponsorBlock που θα αφαιρεθούν ή θα επισημανθούν στο αρχείο βίντεο Κατηγορίες SponsorBlock Αυτόματος έλεγχος για την τελευταία έκδοση στο GitHub Η τρέχουσα έκδοση είναι ενημερωμένη Απέτυχε η ενημέρωση στην τελευταία έκδοση Ενημέρωση Χρησιμοποιήστε το aria2c ως εξωτερικό πρόγραμμα λήψης ‐Cookies Χρήση cookies με μορφοποίηση Netscape για λήψεις Εκκαθάριση προσωρινών αρχείων Διαγραφή όλων των προσωρινών αρχείων από τον προσωρινό κατάλογο Διαγράφηκαν %1$d προσωρινά αρχεία Λειτουργία Multiselect Απενεργοποίηση ιστορικού λήψης Δυναμικό χρώμα Εφαρμόστε χρώματα από ταπετσαρίες στο θέμα της εφαρμογής Λήψη μέσω κινητής τηλεφωνίας Επιτρέψτε τη λήψη πολυμέσων όταν είστε συνδεδεμένοι σε μετρημένα δίκτυα Η λήψη με δίκτυο κινητής τηλεφωνίας είναι απενεργοποιημένη σύμφωνα με τις ρυθμίσεις σας Αυτό το αρχείο δεν είναι πλέον διαθέσιμο Δίκτυο Όριο ποσοστού Περιορισμός του μέγιστου ρυθμού λήψης Μέγιστο ποσοστό Σκούρο θέμα υψηλής αντίθεσης Μη έγκυρη εισαγωγή Χαμηλότερη ποιότητα Μη διαθέσιμο ‐Μορφή αρχείου, ποιότητα βίντεο, υπότιτλοι Έκδοση Yt-dlp, ειδοποίηση, λίστα αναπαραγωγής Όριο ρυθμού, downloader, cookies Απενεργοποίηση προεπισκόπησης Δεν εμφανίζονται μικρογραφίες κατά τη λήψη Απόρρητο Χρήση προσαρμοσμένης εντολής Ιδιωτικός κατάλογος Αποθήκευση λήψεων σε έναν κρυφό κατάλογο Έργο τέχνης Crop Περικοπή ενσωματωμένης εικόνας σε τετράγωνο Επιλέξτε βίντεο για λήψη από τη λίστα αναπαραγωγής \"%1$s\" Επιλέξτε όλα %1$d επιλεγμένο Βίντεο (χωρίς ήχο) Προτεινόμενο Επιλογή μορφής Επιλέξτε τη μορφή λήψης πριν ξεκινήσετε τη λήψη Δημιουργία νέων cookies Χρήση Cookies Να αφαιρέσετε αυτή την καταχώρηση για το \"%1$s\"; Σημειώστε ότι τα cookies που έχουν αποθηκευτεί για αυτόν τον ιστότοπο δεν θα διαγραφούν. Ορισμένες επιλογές δεν είναι διαθέσιμες όταν χρησιμοποιείτε προσαρμοσμένη εντολή Η λήψη από ορισμένες τοποθεσίες απαιτεί πληροφορίες ελέγχου ταυτότητας λογαριασμού. Κάντε κλικ στην επιλογή \"Δημιουργία νέων cookies\", εισαγάγετε τη διεύθυνση URL του ιστότοπου και, στη συνέχεια, συνδεθείτε με το λογαριασμό σας στη σελίδα του προγράμματος περιήγησης, η εφαρμογή θα τα δημιουργήσει για εσάς. Πώς λειτουργεί; Κανάλι Telegram Χώρος μήτρας Φάκελος κάρτας SD Αυτόματες λεζάντες Λήψη αυτόματης δημιουργίας λεζάντες Γρήγορη λήψη Οι περισσότερες πλατφόρμες ροής βίντεο παραδίδουν ήχο και βίντεο ξεχωριστά, μπορείτε να επιλέξετε και να συγχωνεύσετε μια μορφή μόνο ήχου με μια μορφή μόνο βίντεο σε ένα ενιαίο βίντεο. Δείγμα κειμένου δημιουργού βίντεο Υπότιτλος –Λήψη υποτίτλων Γλώσσες, ενσωμάτωση υποτίτλων, αυτόματες λεζάντες Προσθέστε Εκτέλεση εργασιών Εμφάνιση ημερολογίου Ημερολόγιο Οι υπότιτλοι ενδέχεται να είναι λάθος χρονισμένοι κατά την αφαίρεση τμημάτων SponsorBlock. Για την ενσωμάτωση υποτίτλων, τα βίντεο θα μετατραπούν σε δοχείο mkv. Μπορείτε να χρησιμοποιήσετε το VLC Media Player ή άλλες συμβατές εφαρμογές για να παρακολουθήσετε βίντεο με ενσωματωμένους υπότιτλους. Μοιραστείτε το Σταθερό Προεπισκόπηση Κανάλι ενημέρωσης Αυτόματη ενημέρωση Απορρίψτε το Χαμηλότερο bitrate Ταξινόμηση μορφής Ταξινόμηση μορφών με την επιλογή -S του yt-dlp Εισαγωγή Τίτλος Μετονομασία λεπτό Αποθήκευση προσωρινών αρχείων στον εσωτερικό κατάλογο Χορηγός Υποστηρίξτε αυτή την εφαρμογή με χορηγία στο GitHub Η Seal θα είναι πάντα δωρεάν και ανοιχτού κώδικα για όλους. Αν σας αρέσει, παρακαλώ σκεφτείτε να με χρηματοδοτήσετε στο GitHub! Ανατροφοδότηση Μορφή ήχου Beta Δημιουργήστε βίντεο κλιπ στη σελίδα επιλογής μορφής μετάβαση σε GitHub builds Εντάξει Το πήρα Μήνυμα από τον προγραμματιστή Σας ευχαριστώ πολύ! Μετατροπή υποτίτλων Μετατροπή των υποτίτλων σε άλλη μορφή Το βίντεο θα χωριστεί σε %1$d κεφάλαια Ουπς! Κάτι πήγε στραβά Αντιγραφή και έξοδος Επεκτείνετε το Νέα εργασία λήψης Έναρξη Μεσολάβηση Κληρονομιά Απενεργοποίηση Πατήστε για να ρυθμίσετε τον κατάλογο Προτιμήστε μορφές MP4 (H.264) για κοινή χρήση σε άλλες εφαρμογές Προτιμήστε φορμά AV1, VP9 ή H.265 για παρακολούθηση σε συμβατές εφαρμογές Λήψη τύπου Προσαρμοσμένο Μάθετε περισσότερα Άγνωστος Κεφαλίδα User-Agent Εξαγωγή σε αρχείο Πρότυπο εξόδου Καθορίστε το πρότυπο για τα ονόματα των αρχείων εξόδου Λήψη αρχείου Εκκαθάριση αρχείου λήψης; Ενσωματώστε μεταδεδομένα Ενσωματώστε μεταδεδομένα και μικρογραφία βίντεο στο αρχείο ήχου Απαιτούμενο Εμφάνιση όλων των στοιχείων %1$d Επεξεργασία αρχείου Περιορισμός των ονομάτων αρχείων Ιστοσελίδα Τίτλος λίστας αναπαραγωγής Ρυθμίσεις συστήματος Επιβολή IPv4 Διαμόρφωση πριν από τη λήψη Διαμορφώστε τις προτιμήσεις πριν από τη λήψη Η διαδρομή εξόδου και η διεύθυνση URL θα προστεθούν από την εφαρμογή. Μετατροπή μορφής ήχου Μη προσηλυτισμένο Η επανακωδικοποίηση αρχείων ήχου θα προκαλέσει απώλεια στην ποιότητα του ήχου και αύξηση του μεγέθους του αρχείου. Περιορίστε την ποιότητα του βίντεο όταν υπάρχουν πολλά Προτιμώμενη μορφή βίντεο Μετάτρεψε Μορφή βίντεο Λήψη Κλείστε το Σύνδεσμος βίντεο %d νήμα(τα) θα χρησιμοποιούνταν για την ταυτόχρονη λήψη εγγενών βίντεο DASH/HLS. Μην εμφανιστείτε ξανά Οδηγός χρήσης Στη συνέχεια, κάντε κλικ στο κουμπί \"Λήψη\" αφού προσαρμόσετε τις ρυθμίσεις του. Κάντε κλικ στην επιλογή \"Επικόλληση\" για να λάβετε το σύνδεσμο βίντεο από το πρόχειρο. Ελέγξτε και διαχειριστείτε τις λήψεις εντός της εφαρμογής, συμπεριλαμβανομένων των βίντεο και των αρχείων ήχου. Προεπιλογή Ρίξτε μια ματιά στις ρυθμίσεις λήψης και βεβαιωθείτε ότι έχετε την τελευταία έκδοση του yt-dlp πριν το χρησιμοποιήσετε. Λήψη λίστας αναπαραγωγής Λήψη πολλαπλών βίντεο από μια λίστα αναπαραγωγής Λήψη Ειδοποίηση για τη λήψη αρχείων και την πρόοδο Το κατέβασμα ολοκληρώθηκε. Πατήστε για να ανοίξετε. Εκτέλεση προσαρμοσμένων εντολών… Παρακαλούμε ρυθμίστε τη χρήση της μπαταρίας αυτής της εφαρμογής σε \"Χωρίς περιορισμούς\" στις ρυθμίσεις συστήματος για λήψη στο παρασκήνιο. Παράλληλη λήψη περισσότερων τμημάτων βίντεο M3U8/MPD Λήψη με πολλαπλά νήματα Επιλογή λίστας αναπαραγωγής Δεν υποστηρίζονται κατάλογοι εκτός των Download/ και Documents/ Η Seal κατεβάζει… Άγνωστο σφάλμα Μεταφράστε Βοήθεια για τη μετάφραση αυτής της εφαρμογής στο Hosted Weblate Πρόθεμα Ενσωματώστε υπότιτλους Αφαιρέστε; Επιλογή προτύπου Έλεγχος για ενημερώσεις ”Τα προσωρινά αρχεία μπορούν να χρησιμοποιηθούν για την επανάληψη των ακυρωμένων λήψεων. Είστε σίγουροι ότι διαγράψατε όλα αυτά τα αρχεία; \n \nΜπορείτε να έχετε πρόσβαση σε αυτά τα αρχεία στο %1$s Το yt-dlp είναι ένα ισχυρό εργαλείο γραμμής εντολών για τη λήψη βίντεο. Το Seal διευκολύνει τη χρήση του yt-dlp παρέχοντας ένα διαισθητικό γραφικό περιβάλλον, προεπιλογές για κοινές εντολές και άλλα πρόσθετα χαρακτηριστικά. \n \nΓια προχωρημένη χρήση του yt-dlp, το Seal σας επιτρέπει να δημιουργείτε, να αποθηκεύετε και να εκτελείτε προσαρμοσμένα πρότυπα εντολών απευθείας, ακριβώς όπως σε ένα τερματικό. \n \nΌταν χρησιμοποιείτε προσαρμοσμένες εντολές, οι περισσότερες επιλογές και δυνατότητες του GUI θα είναι απενεργοποιημένες. Δείγμα κειμένου τίτλου βίντεο Γλώσσες υποτίτλων Αντιγραφή ημερολογίου Σαφής Συντομεύσεις επεξεργασίας Συντομεύσεις Επεξεργαστείτε τις προσαρμοσμένες συντομεύσεις που μπορούν να χρησιμοποιηθούν για τη σύνθεση προτύπων εντολών. Εγκαταστήστε pre-release builds για προεπισκόπηση νέων χαρακτηριστικών και αλλαγών. \n \nΘα υπάρχει κάποια αστάθεια σε αυτές τις εκδόσεις, γι\' αυτό μη διστάσετε να μας δώσετε ανατροφοδότηση αν αντιμετωπίσετε κάποιο πρόβλημα για να μας βοηθήσετε να βελτιώσουμε την εφαρμογή στο μέλλον. Ενεργοποίηση αυτόματης ενημέρωσης Εφαρμογή Βίντεο κλιπ Έναρξη ‐Τέλος Προτιμώμενη μορφή ήχου Απεριόριστα Ποιότητα ήχου Περιορισμός του ρυθμού μετάδοσης ήχου όταν υπάρχουν πολλαπλές ποιότητες δεύτερο Διαγραφή όλων των cookies Να διαγράψετε οριστικά όλα τα cookies που είναι αποθηκευμένα στην εφαρμογή; Χορηγοί Δεν υπάρχουν κατεβασμένα πολυμέσα Ενεργοποίηση πειραματικής λειτουργίας; Οι λήψεις που χρησιμοποιούν αυτή τη λειτουργία θα ανατεθούν στο FFmpeg για να κατεβάσει επιλεγμένα τμήματα του βίντεο, αυτή η λειτουργία είναι ακόμα πειραματική και η κοπή δεν θα είναι απόλυτα ακριβής, δεν υποστηρίζουν όλες οι μορφές αυτή τη λειτουργία και ενδέχεται να έχετε χαμηλότερες ταχύτητες λήψης. Η αυτόματη ενημέρωση δεν είναι διαθέσιμη για %1$s builds. Αν δεν έχετε εγκαταστήσει το %1$s στη συσκευή σας ή αν θέλετε να κάνετε προεπισκόπηση των επερχόμενων νέων χαρακτηριστικών στο Seal, παρακαλούμε εξετάστε το %2$s. Χαρακτηριστικό δεν είναι διαθέσιμο Δεν υπάρχουν προσαρμοσμένες εργασίες εντολών Λήψη βίντεο από τη διεύθυνση URL Διαχωρισμένο βίντεο Επεξεργασία \"%1$s\" Χρήση διακομιστή μεσολάβησης για συνδέσεις στο διαδίκτυο Ποιότητα Ενεργοποίηση ειδοποιήσεων; Η εφαρμογή χρειάζεται την άδειά σας για να δημοσιεύει ειδοποιήσεις σχετικά με την κατάσταση και την πρόοδο της λήψης. Κατάλογος προσαρμοσμένων εντολών Επιλογή φακέλου Άτομα με ειδικές ανάγκες Καθορίστε τον κατάλογο εξόδου όταν χρησιμοποιείτε προσαρμοσμένες εντολές Auto Εντολές Προτίμηση μορφής Πατήστε για να ανοίξετε την ιστοσελίδα για τη δημιουργία νέων cookies: Αφαιρέστε οριστικά το %1$s από τα πρότυπα εντολών; Προεπιλογές Αρχείο Εξαγωγή ιστορικού λήψης; Πρόχειρο Εισαγωγή από Καταγράψτε τα ID των βίντεο που κατεβάσατε σε ένα αρχείο για να αποφύγετε διπλές λήψεις Να αφαιρέσετε οριστικά το %1$s από το αρχείο αρχειοθέτησης; Χρήση ταξινόμησης μορφής Περιορίστε τα ονόματα αρχείων σε συγκεκριμένους χαρακτήρες για να διασφαλίσετε τη συμβατότητα Οι λήψεις σας θα αποθηκευτούν ως: Πραγματοποιήστε όλες τις συνδέσεις μέσω IPv4 Διατήρηση αρχείων υποτίτλων Επιτρέψτε πάντα Μην επιτρέπετε ‐Συγχώνευση πολλαπλών ροών ήχου Επιτρέπει τη συγχώνευση πολλαπλών ροών ήχου σε ένα ενιαίο αρχείο Αναζήτηση Θυμηθείτε για την επόμενη λήψη Τύπος αντιγράφων ασφαλείας Αυτόματα μεταφρασμένοι υπότιτλοι Αυτόματα μεταφρασμένοι υπότιτλοι για όλες τις γλώσσες θα είναι διαθέσιμοι στις λήψεις. Αυτοί οι υπότιτλοι ενδέχεται να είναι ανακριβείς και δυσνόητοι. Γλώσσα των υποτίτλων για λήψη στην επιλογή αυτόματης μορφής, διαχωρισμένη με κόμμα. Επαναφορά Αναζήτηση σε υπότιτλους Όχι ευχαριστώ Κανένα Εμφάνιση & αίσθηση Διεπαφή & αλληλεπίδραση Χρήση προηγούμενης επιλογής Εξαγωγή Εισαγωγή Πλήρες αντίγραφο ασφαλείας Εξαγωγή σε Εισαγωγή ιστορικού λήψεων; Εξαγωγή %1$s από το ιστορικό λήψεων. Τα κατεβασμένα αρχεία και οι προτιμήσεις δεν θα δημιουργηθούν αντίγραφα ασφαλείας. Λήψη ιστορικού Οι ακόλουθες γλώσσες θα προστεθούν στις προτιμήσεις σας για μελλοντικές λήψεις: Ενημέρωση γλωσσών υποτίτλων; Εισήχθησαν %1$s στο ιστορικό λήψης Επανακατέβασμα Αναζήτηση στις λήψεις ================================================ FILE: app/src/main/res/values-es/strings.xml ================================================ Haz clic en el botón de «Pegar» para obtener el enlace desde tu portapapeles. Para descargar en segundo plano, por favor cambia el ajuste de uso de batería en los ajustes del teléfono a «No restringir». No se pudo instalar la última versión de yt-dlp. Por favor asegúrate de estar conectado al Internet. Una descarga existente se está ejecutando El directorio de salida y la URL serán añadidas por la aplicación. Ubicación de los vídeos Guardar como audio Guardar miniatura Ajustes Ajustes en general, formato y comandos personalizados Descargar El enlace no debe estar vacío Descargar y guardar el audio, en vez del vídeo Guardar la miniatura del vídeo como un archivo aparte Usando la última versión de yt-dlp Permiso denegado Descarga finalizada Ocurrió un error al descargar Descargando «%1$s» No se pudo obtener la información del vídeo General Idioma Seleccionar idioma Pegar url Versión de yt-dlp Haz clic para descargar la última versión de yt-dlp ¿Eliminar? Confirmar Cancelar Descargas Audio Enlace copiado al portapapeles Abrir enlace Eliminar Eliminar archivo Acerca de Versión, comentarios, actualización automática Ir atrás Versión Buscar actualizaciones e historial de cambios Último lanzamiento Ver repositorio de GitHub y README Vídeo Seleccionado Créditos No se ha podido pegar la URL desde el portapapeles ¿Eliminar definitivamente \"%1$s\" del historial de descargas? Créditos y software de código abierto Comandos personalizados Ejecutar un comando de yt-dlp con un modelo personalizado Modelo de comando Editar modelo Iniciar la ejecución del comando Avanzado Salida detallada Mostrar en pantalla mensajes relevantes durante la descarga Aspecto Modo oscuro, colores dinámicos, idioma Tema oscuro Sistema Activado Desactivado Cancelar Configurar antes de la descarga Configurar ajustes antes de descargar Configurar esta descarga El error reportado ha sido copiado al portapapeles Miniatura Pegar enlace Referencias de uso para yt-dlp Convertir formato de audio No convertir Convertir a %1$s Formato La recodificación de archivos de audio conlleva una pérdida de calidad de sonido y un aumento en el tamaño del archivo final. Calidad del vídeo Mejor calidad Limitar la calidad de vídeo cuando hay varios Sin especificar (por defecto) Preferencia de formato de vídeo Formato preferido cuando se facilitan varios Formato de vídeo Convertir audio Iniciar descarga Cerrar No mostrarlo otra vez Guía del usuario Abrir ajustes Después de realizar los ajustes pertinentes, haz clic en el botón «Descargar». Verifica y administra las descargas dentro de la aplicación, incluyendo videos y archivos de audio. Se recomienda echar un vistazo rápido a los ajustes y asegurarse de tener yt-dlp antes de empezar a usar la aplicación. Descargar lista de reproducción Descargar varios vídeos de una lista de reproducción Predeterminado Descargar Mostrar el progreso de las descargas y un mensaje de finalización Enlace del vídeo Descarga con múltiples hilos Descargar múltiples fragmentos de vídeos m3u8/mpd en paralelo Opciones Ajustes adicionales No se ha podido obtener la URL desde el contenido compartido Leyendo información del enlace compartido con Seal… Mostrar más acciones Notificación de descarga Notificarme el progreso de las descargas y un mensaje de finalización Selección de la lista de reproducción Inicio Fin Rango de vídeos inválido Descarga finalizada. Pulse para abrir. %d subproceso(s) serían usados para descargar vídeos nativos DASH/HSL concurrentemente. Adquiriendo información de la lista de reproducción… Especifica el rango de vídeos que quieres descargar desde la lista de reproducción \"%3$s\"(desde %1$d hasta %2$d ). Ubicación de audios Directorios de descargas Configurar ubicación de descarga de vídeos y audios Guardar en subdirectorio Problema con permiso de almacenamiento Directorios fuera de las carpetas «Download/» y «Documents/» no están soportados Configuración de la batería Ignorar la optimización de la batería para esta aplicación en segundo plano Seal está descargando… Ha ocurrido un error desconocido Traducir Ayuda a traducir esta aplicación en Hosted Weblate Ejecutando comandos personalizados… Descargando la lista de reproducción (%1$d/%2$d)… Guardar los archivos en carpetas denominadas como campos respectivos Adquiriendo información del vídeo… Plantilla de ruta Agregar subtítulos Insertar subtítulos proporcionados en videos si están disponibles Nueva plantilla Etiqueta ¿Eliminar\? ¿Eliminar «%1$s» de las plantillas de comandos para siempre\? Selección de plantillas Editar y gestionar plantillas de comandos En cola Finalizado Descargando Cancelada Obteniendo información Abrir archivo Reinciar Descarga en curso… Tarea de descarga cancelada Reportar un problema en GitHub Envía un reporte de un problema que te ha ocurrido o haz una petición de alguna funcionalidad que te gustaría que tuviera Seal Información copiada al portapapeles Copiar enlace Copiar el informe Error Resolución del vídeo Tamaño del archivo del vídeo Exportar al portapapeles Importar desde el portapapeles %1$d plantilla(s) exportada(s) %1$d plantilla(s) importada(s) %1$d tareas de descarga Añadido recientemente %1$d vídeo(s), %2$d archivo(s) de audio ¿Eliminar %1$d elemento(s) de tu historial de descargas para siempre\? Buscar automáticamente por la última versión disponible en GitHub Seal ya está puesto al día Actualizar Buscar actualizaciones Usa aria2c como descargador externo Fallo al actualizar a la última versión Eliminar o marcar segmentos en los vídeos con la API SponsorBlock Especifique las categorías en SponsorBlock que deseas eliminar o marcar en el archivo de vídeo Categorías de SponsorBlock Usar cookies formateadas de Netscape para las descargas Limpiar archivos temporales %1$d archivo(s) temporales eliminados Los archivos temporales pueden utilizarse para reanudar las descargas canceladas. ¿Está seguro de eliminar todos estos archivos\? \n \nPuede acceder a estos archivos en %1$s Eliminar todos los archivos temporales del directorio temporal Modo de multiselección Incógnito Desactiva el almacenamiento de carátulas/miniaturas Color dinámico Aplicar los colores de los fondos de pantalla al tema de la aplicación Descargar con datos móviles Permitir la descarga de medios cuando se está conectado a redes de uso medido La descarga con la red móvil está desactivada según tu configuración Este archivo ya no está disponible Limitar la velocidad máxima de descarga Tema de alto contraste (AMOLED) Redes e Internet Límite de uso de datos Límite máximo Entrada inválida Calidad más baja No disponible Formato de archivo, calidad de vídeo, subtítulos Versión de yt-dlp, notificaciones, listas de reprodución Límite de uso de datos, descargador, cookies Privacidad Usar comandos personalizados Almacena las descargas en un directorio oculto Recortar carátulas Recortar la imagen incrustada en un cuadrado Deshabilitar vista previa No se muestran miniaturas durante la descarga Directorio privado Seleccionar todo Seleccione los vídeos que desea descargar de la lista de reproducción \"%1$s\" %1$d seleccionado Sugerido Selecciona el formato de vídeo que quieres antes de comenzar la descarga Vídeo (sin audio) Selección de formato Crear nuevas cookies Utilizar cookies ¿Eliminar esta entrada para \"%1$s\"? Por favor, tenga en cuenta que las cookies almacenadas para este sitio no se borrarán. Algunas opciones no están disponibles cuando se utiliza el comando personalizado ¿Cómo funciona\? Canal de Telegram La descarga desde algunos sitios requiere información de autenticación de cuenta. Haz clic en \"Generar nuevas cookies\", introduce la URL del sitio web y, a continuación, inicia sesión con tu cuenta en la página del navegador; la aplicación la generará por ti. Cookies Canal de Matrix La mayoría de los servicios de streaming en línea proveen el audio y el vídeo por separado. De esta forma puedes seleccionar mezclar un formato de solo vídeo con otro de solo audio para crear un único vídeo con audio, dando así una mayor personalización. Ubicación para tarjetas SD Subtítulos generados automáticamente Descarga subtítulos autogenerados Descarga rápida Texto de ejemplo en título de vídeo Texto de ejemplo de creador de vídeo Subtítulo Descargar subtítulos Idiomas de los subtítulos Idiomas, subtítulos incorporados, subtítulos automáticos Copiar reporte Limpiar Editar accesos directos Añadir Accesos directos Tareas ejecutándose Mostrar registro Registro Edita los accesos directos a los argumentos que puedes usar para crear plantillas de comandos. Los subtítulos pueden estar mal sincronizados cuando se eliminan segmentos del SponsorBlock. Para insertar subtítulos suaves, los videos se remuxificarán en el contenedor mkv. Puede utilizar VLC Media Player u otras aplicaciones compatibles para ver vídeos con subtítulos suaves. %.2f MB %.2f GB Compartir Actualizar el canal Actualizar automáticamente Habilitar la actualización automática Vista previa Instala versiones preliminares para probar nuevas funciones y cambios. \n \nHabrá cierta inestabilidad en estas versiones, así que no dudes en hacernos llegar tus comentarios si experimentas algún problema para ayudarnos a mejorar la aplicación en el futuro. Estable Descartar Aplicar Videoclip Iniciar Finalizar Formato de audio preferido Ilimitado Tasa de bits más baja Calidad del audio Título Clasificación de formatos con la opción -S de yt-dlp Limpiar todas las cookies minuto Renombrar Limitar la velocidad de bits del audio cuando hay varias calidades disponibles Clasificación por formatos Importar segundo ¿Borrar definitivamente todas las cookies almacenadas en la aplicación\? Almacena archivos temporales en el directorio seleccionado Patrocinador Apoya esta aplicación patrocinando en GitHub Seal será siempre gratuito y de código abierto para todos. Si te gusta, ¡considera la posibilidad de patrocinarme en GitHub! Comentario Patrocinadores Formato del audio Ningún medio descargado Beta ¿Habilitar la función experimental\? Hacer videoclips en la página de selección del formato Las descargas que utilicen esta función serán delegadas a FFmpeg para que descargue las secciones seleccionadas del vídeo, esta función es todavía experimental y el corte no será completamente preciso, no todos los formatos soportan esta función y puede que experimentes velocidades de descarga más lentas. cambiando a las compilaciones de GitHub De acuerdo Entendido La actualización automática no está disponible para %1$s builds. Si no tienes %1$s instalado en tu dispositivo, o te gustaría tener una vista previa de las próximas novedades de Seal, considera %2$s. Característica no disponible No hay tareas de comando personalizadas Mensaje del desarrollador ¡Muchas gracias! Descargar los vídeos desde la URL Convertir subtítulos El video se dividirá en %1$d capítulos Convertir los subtítulos en otro formato Dividir video Copiar y salir Oops! Algo salió mal Expandir Nueva tarea de descarga Iniciar Editar \"%1$s\" Actualizar yt-dlp Proxy Utilizar un proxy para las conexiones a Internet La aplicación necesita tu permiso para enviar notificaciones sobre el estado y el progreso de la descarga. Desactivar Pulsa para configurar el directorio Selector de carpetas Especifica el directorio de salida cuando utilices los comandos personalizados Prefieres los formatos AV1, VP9 o H.265 para verlos en las aplicaciones compatibles Legado Calidad ¿Activar las notificaciones\? Directorio de comandos personalizado Desactivado Prefieres los formatos MP4(H.264) para compartir con otras aplicaciones Pulsa para abrir la página web para generar nuevas cookies: Tipo de descarga Personalizada Automática Desconocido Comandos Preferencia del formato Más información ¿Quitar %1$s de las plantillas de los comandos para siempre\? %d elemento %d elementos %d elementos Encabezado User-Agent Exportar a un archivo yt-dlp es una potente herramienta de línea de comandos para descargar vídeos. Seal facilita el uso de yt-dlp proporcionando una GUI intuitiva, preajustes para comandos comunes y otras características adicionales. \n \nPara un uso avanzado de yt-dlp, Seal permite crear, guardar y ejecutar plantillas de comandos personalizadas directamente, como en un terminal. \n \nAl utilizar comandos personalizados, la mayoría de las opciones y características de la GUI estarán desactivadas. ¿Borrar el archivo de las descargas\? ¿Eliminar %1$s del archivo de forma definitiva\? Preajustes Formato de salida Especifica la plantilla para los nombres de los archivos de salida Registra los ID de los vídeos descargados en un archivo para evitar descargas duplicadas Descargar el archivo Insertar los metadatos Insertar los metadatos y las miniaturas del vídeo en el archivo de audio Requerido Mostrar todos los %1$d elementos Guardar Modificar el archivo Utilizar la clasificación por formato Limitar los nombres de archivo a determinados caracteres para garantizar la compatibilidad Restringir los nombres de archivo Página web Título de la lista de reproducción Tus descargas se guardarán como: Forzar IPv4 Configuración del sistema Realizar todas las conexiones a través de IPv4 Guardar los archivos de los subtítulos Permitir una vez No permitir Permitir siempre ¿Permitir descargas a través de datos móviles? Fusionar varios flujos de audio Permite fusionar varios flujos de audio en un único archivo Buscar Buscar en descargas Subtítulos traducidos automáticamente Idioma de los subtítulos a descargar en la selección de formato automático, separados por comas. Recordar para la próxima descarga Utilizar la selección anterior Ninguno Restablecer Buscar en los subtítulos Los siguientes idiomas se añadirán a sus preferencias para futuras descargas: ¿Actualizar los idiomas de los subtítulos? No, gracias Los subtítulos traducidos automáticamente para todos los idiomas estarán disponibles en las descargas. Estos subtítulos pueden ser inexactos y difíciles de entender. Importar desde ¿Exportar el historial de descargas? Portapapeles ¿Importar el historial de descargas? Exportando %1$s del historial de descargas. No se realizará una copia de seguridad de los archivos descargados ni de las preferencias. Exportar Importar Copia de seguridad completa Tipo de copia de seguridad Exportar a Archivo Los archivos descargados no se importarán. Tendrás que volver a descargarlos manualmente Descargar el historial Importado %1$s al historial de descargas Descargar de nuevo El vídeo se ha descargado. Si este no es el comportamiento esperado, compruebe su archivo de descargas. Contenedor de vídeo Remux Remezcla vídeos en contenedores MKV para mejorar la compatibilidad A diario Semanalmente Mensual %1$d cookies de %2$d páginas web en total Continuar Programar Elige entre formatos, subtítulos y personaliza aún más Editar ajuste preestablecido %d vídeo %d vídeos %d vídeos %d audio %d audios %d audios Todos los idiomas Lista de reproducción Prefiero %1$s Descargar automáticamente utilizando tus preferencias de formato Descargar en el mejor formato disponible Tarea añadida a la cola Encontrarás tus descargas aquí Pulsa el botón de descarga o comparte un enlace de vídeo en esta aplicación para iniciar la descarga Descargado Todo Cola de descarga Seleccione entre %1$d enlaces Mostrar cajón de navegación Borrar Resumen Información multimedia Rastreador de problemas Soluciona errores comunes y comprueba si hay errores conocidos Diagnosticar y resolver un problema ¿Ha encontrado un error? Antes de reportar un nuevo problema, por favor busque en nuestro rastreador de problemas. Muchos problemas comunes ya se han solucionado y documentado allí. Añadir a %1$s Añadir nuevo enlace Enlaces guardados ================================================ FILE: app/src/main/res/values-eu/strings.xml ================================================ Bideoen kokapena Gorde audio bezala Gorde miniatura Ezarpenak Deskargatu Ezarpena orokorrak, formatua eta komando pertsonalizatuak Estekaren hutsuneak ez luke hutsik egon behar Audioa deskargatu eta gorde, bideoaren ordez Gorde bideoaren miniatura fitxategi bat bezala yt-dlp osagaia eguneratuta dago Ezin izan da yt-dlp eguneratu. Mesedez, ziurtatu Interneterako konexioa duzula. Bideoari buruzko informazioa eskuratzen… Baimena ukatu egin da Deskarga amaituta Errorea gertatu da deskargatzean «%1$s» deskargatzen Errorea gertatu da bideoko informazioa lortzen saiatzean Orokorra Hizkuntza Hautatu hizkuntza Deskarga bat egiten ari dira. Itxaron amaitu arte. Itsatsi URLa paper-zorrotik Ezin izan da URLa paper-zorrotik itsatsi yt-dlp bertsioa Egin klik yt-dlp osagaia eguneratzeko Historiatik ezabatu\? Ziur al zaude deskargen historialetik «%1$s»-ko fitxategiak ezabatuko dituzula\? Baieztatu Ezeztatu Deskargak Audioa Esteka paper-zorrora kopiatu da Ireki esteka Ezabatu Ezabatu fitxategia Honi buruz Bertsioa, aldeketen erregistroa, kredituak Atzera joan Bertsioa Bilatu eguneratzeak eta aldaketen erregistroa Azken aldaketa Ikusi GitHub biltegia eta README Bideoa Hautatua Kredituak Kredituak eta kode irekiko softwarea Komando pertsonalizatuak Komando-txantiloia Exekutatu yt-dlp komando bat txantiloi pertsonalizatu batekin Eredua editatu Komandoa hasi exekutatzen Aurreratua Irteera zehatza Pantailan mezu garrantzitsuak erakutsi deskargatzean Itxura Itxura iluna, kolore dinamikoak, hizkuntzak Itxura iluna Sistemaren itxura Aktibatuta Desaktibatuta Ezeztatu Konfiguratu deskargatu aurretik Ezarpenak konfiguratu deskargatu aurretik Deskarga honetarako lehentasunak konfiguratu Errorea paperzorroan kopiatu da miniatura Esteka itsatsi yt-dlp-rako erabilera-erreferentziak Irteera-direktorioa eta URLa automatikoki erantsiko ditu aplikazioak. ================================================ FILE: app/src/main/res/values-fa/strings.xml ================================================ بارگیری پوشه ویدئو استفاده از آخرین نسخه yt-dlp در حال دریافت اطلاعات ویدئو… دسترسی رد شد بارگیری به اتمام رسید فایل بارگیری نشد نمایش زبان تنظیم زبان یک عملیات بارگیری در حال انجام می باشد جای‌گذاری پیوند نسخه Yt-dlp برای نصب آخرین نسخه yt-dlp کلیک کنید حذف ؟ برای همیشه \"%1$s\" از سابقه دانلود شما حذف شود؟ تایید پیوند در کلیپ بورد کپی شد حذف فایل درباره نسخه ،بازخورد، بروزرسانی خودکار برگشت نسخه آخرین نسخه به مخزن برنامه در گیتهاب سر بزنید و دستورالعمل ها را مطالعه کنید ویدئو بررسی شد دستور سفارشی قالب دستور اجرای دستور را شروع کنید پیشرفته هنگام دانلود ، پیام اطلاعات را نمایش دهد نمایش تم سیاه، رنگ داینامیک ، زبان ها تم تاریک سیستم روشن انصراف ذخیره به عنوان صدا ذخیره تصویر شاخص تنظیمات پیوند نمی تواند خالی باشد دخیره تصویر شاخص ویدئو به عنوان یک فایل بارگیری و ذخیره صدای ویدئو آخرین نسخه yt-dlp نصب نشد ، لطفا از اتصال دستگاه خود به اینترنت اطمینان حاصل فرمایید. \"%1$s\" بارگیری اطلاعات ویدئو دریافت نشد عمومی با پیوند موجود در کلیپ بورد مطابقت ندارد بارگیری‌ها انصراف صدا باز کردن پیوند حذف سازندگان دستور yt-dlp را با قالب سفارشی اجرا کنید سازندگان و کتابخانه نرم افزار ویرایش خاموش عمومی، فرمت، دستور سفارشی مشاهده تغییرات و نسخه جدید خروجی انتخابی تنظیم دانلود کپی گزارش خطا در کلیپ بورد پیش‌نمایش چسباندن مراجع استفاده از Yt-dlp تبدیل فرمت صدا تبدیل به %1$s فرمت صدا با فرمت MP3 یا M4A در اکثر دستگاه ها کار می کند. فرمت ترجیحی زمانی که چندین مورد دسترس باشند بستن دوباره نمایش نده باز کردن تنظیمات فرمت ویدئو انتخابی پس از تنظیم تنظیمات آن، روی \"دانلود\" کلیک کنید. دانلودهای درون برنامه ای، از جمله فیلم ها و فایل های صوتی را بررسی و مدیریت کنید. دانلود لیست پخش چندین ویدیو را از یک لیست پخش دانلود کنید پیش‌فرض دانلود لطفاً برای بارگیری در پس‌زمینه، بهینه ساز باتری این برنامه را در تنظیمات سیستم روی \"بدون محدودیت\" تنظیم کنید. دانلود چند قسمتی قسمت های بیشتری از ویدیوهای M3U8/MPD را به صورت موازی دانلود کنید خواندن پیوند ویدیو از محتوای اشتراک‌گذاری شده… نمایش اقدامات بیشتر اعلان دانلود در حال دریافت اطلاعات لیست پخش… انتخاب لیست پخش محدوده ی ویدیوها را برای دانلود از لیست \"%3$s\" پخش مشخص کنید (از %1$d تا %2$d). شروع از پایان از در حال دانلود لیست پخش (%1$d/%2$d)… پوشه صدا فایل‌ها را در پوشه‌هایی با نام حیطه مربوطه ذخیره کنید مشکل در مجوز ذخیره سازی دایرکتوری های خارج از Download/ و Documents/ پشتیبانی نمی شوند Seal در حال دانلود است… بهترین کیفیت (پیش فرض) قبل از دانلود پیکربندی کنید تنظیمات برگزیده را قبل از دانلود پیکربندی کنید مسیر خروجی و پیوند توسط برنامه اضافه خواهد شد. تبدیل نشده کیفیت ویدئو در صورت وجود چندگانه، کیفیت فرمت ویدئو و اندازه فایل را محدود کنید مشخص نشده (پیش فرض) تبدیل بهینه سازی باتری را برای دانلود در پس زمینه این برنامه نادیده بگیرید ترجمه فرمت ویدئو اطلاع از فایل های دانلود شده و پیشرفت خطای ناشناخته بارگیری راهنمای کاربر مطابقت پیوند از محتوای مشترک ممکن نیست به ترجمه این برنامه در Weblate میزبانی شده کمک کنید برای دریافت پیوند ویدیو از کلیپ‌بورد، روی \"جای‌گذاری\" کلیک کنید. به تنظیمات دانلود نگاهی بیندازید و قبل از استفاده مطمئن شوید که آخرین نسخه yt-dlp را دارید. اطلاع از فایل های دانلود شده و پیشرفت دانلود پیوند ویدئو دانلود تمام شد. برای باز کردن ضربه بزنید. در حال اجرای دستور سفارشی … ا ز%d شته(های) برای دانلود همزمان ویدیوی بومی DASH/HLS استفاده می شود. پوشه دانلود گزینه ها تنظیمات اضافی محل ذخیره فیلم ها و فایل های صوتی را انتخاب کنید ذخیره در زیر شاخه پوشه محدوده شاخص نامعتبر است پیکربندی باتری فرمت صوتی ترجیحی نامحدود کمترین میزان بیت ریت کیفیت صدا وقتی کیفیت های متعدد وجود دارد، میزان بیت ریت صدا را محدود کنید مرتب سازی فرمت مرتب سازی فرمت ها با گزینه -S yt-dlp وارد كردن عنوان دقیقه ثانیه حذف تمام کوکی ها تمام کوکی های ذخیره شده در برنامه را برای همیشه حذف کنید؟ %.2f گیگ‌بایت ورودی نامعتبر پیشنهادی ممکن است هنگام حذف بخش‌های SponsorBlock، زیرنویس‌ها از بین بروند. اشتراک پایدار پیش نمایش برای پیش نمایش ویژگی ها و تغییرات جدید، نسخه پیش از انتشار را نصب کنید. \n \nدر این نسخه‌ها مقداری بی‌ثباتی وجود خواهد داشت، بنابراین لطفاً در صورت بروز هرگونه مشکل در ارائه بازخورد به ما دریغ نکنید تا به ما در بهبود برنامه برای آینده کمک کنید. به روز رسانی کانال بروزرسانی خودکار فعال سازی بروزرسانی خودکار %1$d مورد(ها) از سابقه بارگیری شما برای همیشه حذف شود؟ نمایش گزارش گزارش در صف قرار گرفته است به پایان رسید حالت چند انتخابی حذف یا علامت گذاری بخش‌های ویدئو با کمک سرویس SponsorBlock برای دانلود از کوکی های فرمت شده Netscape استفاده کنید از فایل‌های موقت می‌توان برای از سرگیری دانلودهای لغو شده استفاده کرد. آیا مطمئن هستید که همهٔ این فایل‌ها را حذف می‌کنید؟ \n \nمی‌توانید این فایل‌ها را در %1$s ببینید حد نرخ محدودیت نرخ، دانلود کننده، کوکی ها غیرفعال کردن پیش نمایش هنگام دانلود برش آثار هنری دانلودها را در یک دایرکتوری مخفی ذخیره شود تصویر جاسازی شده را به مربع برش دهید ویدئو (بدون صدا) دانلود زیرنویس های خودکار ساخته شده برخی از گزینه ها هنگام استفاده از دستور سفارشی در دسترس نیستند دانلود از برخی سایت ها به اطلاعات احراز هویت حساب کاربری نیاز دارد. روی «ایجاد کوکی‌های جدید» کلیک کنید، آدرس وب سایت را وارد کنید و سپس با حساب کاربری خود در صفحه مرورگر وارد شوید، برنامه آن را برای شما تولید می‌کند. %.2f مگابایت ویرایش نام دانلود با استفاده از اینترنت سیم کارت هنگامی که به شبکه‌های اندازه‌گیری شده متصل می‌شوید، بارگیری رسانه را مجاز کنید دانلود با شبکه تلفن همراه با توجه به تنظیمات شما غیرفعال است رد کردن اعمال کلیپ ویدیو شروع پایان بررسی بروزرسانی فایل های موقت را پاک کنید کاناال تلگرام عملیات دانلود لغو شد رنگ ها را از والپیپرها به تم برنامه اعمال کنید %1$d فایل(های) موقت حذف شد قبل از شروع دانلود، فرمت مورد نظر را برای دانلود انتخاب کنید دانلود در حال انجام است… دایرکتوری خصوصی چسباندن زیرنویس ساخت الگو جدید کپی پیوند نسخه فعلی به روز است تمام فایل های موقت را از پوشه موقت حذف کنید غیرفعال کردن پیش نمایش حریم خصوصی انتخاب دستور سفارشی انتخاب همه راه اندازی مجدد خطا کپی گزارش کیفیت ویدیو به آخرین نسخه به روز رسانی نشد بروزرسانی به طور خودکار آخرین نسخه را در GitHub بررسی کنید کوکی ها انتخاب فرمت ساخت کوکی جدید در صورت موجود، زیرنویس‌‌های جداگانه بر روی ویدیوها نصب شوند عنوان آیا حذف شود؟ برای همیشه \"%1$s\" از الگوهای خوب حذف شود؟ انتخاب الگو الگو های فرمان را ویرایش و مدیریت کنید یک درخواست برای گزارش اشکال یا درخواست ویژگی ارسال کنید اطلاعات در کلیپ بورد کپی شد ارسال مشکل در گیتهاب لغو شد در حال دریافت اطلاعات باز کردن فایل استخراج به کلیپ برد %1$d عملیات در حال دانلود انتخاب کوکی ها این ورودی به ازای «%1$s» حذف شود؟ لطفا بخاطر داشته باشید که کوکی‌های ذخیره شده در این سایت پاک نمی‌شوند. چگونه کار می کند؟ فضای ماتریس به تازگی اضافه شده در حال دانلود اندازه فایل ویدیویی وارد کردن از کلیپ برد %1$d الگو(های) صادر شد %1$d الگو(های) وارد شد %1$d ویدیو، %2$d فایل صوتی دسته‌بندی‌های SponsorBlock را برای حذف از فایل ویدئو یا علامت‌گذاری در آن مشخص کنید دسته های SponsorBlock از aria2c به عنوان دانلودر خارجی استفاده کنید حالت خصوصی غیرفعال کردن سابقه دانلود رنگ پویا این فایل دیگر در دسترس نیست شبکه حداکثر سرعت بارگیری را مشخص کنید حداکثر تعداد تم تیره با کنتراست بالا پایین ترین کیفیت در دسترس نیست فرمت فایل، کیفیت ویدئو، زیرنویس نسخه Yt-dlp، اعلان، لیست پخش ویدیوها را برای بارگیری از لیست پخش \"%1$s\" انتخاب کنید %1$d مورد انتخاب شد پوشه حافظه خارجی زیرنویس های خودکار دانلود سریع اکثر پلتفرم‌های پخش ویدیو صدا و تصویر را به طور جداگانه ارائه می‌کنند، می‌توانید یک فرمت فقط صوتی را با یک فرمت فقط ویدیویی در یک ویدیو انتخاب و ادغام کنید. متن نمونه عنوان ویدیو متن نمونه سازنده ویدیو زیرنویس دانلود زیرنویس ها زبان های زیرنویس زبان‌ها، قرار دادن زیرنویس‌ها، زیرنویس‌های خودکار کپی گزارش ها پاک سازی ویرایش میانبر افزودن شورتکات ها میانبرهای سفارشی را ویرایش کنید که می توان از آنها برای نوشتن الگوهای فرمان استفاده کرد. عملیات های در حال اجرا برای جاسازی زیرنویس‌ها، فیلم‌ها به فرمتmkv تبدیل می‌شوند. شما می‌توانید از VLC Media Player یا سایر برنامه‌های سازگار برای تماشای فیلم‌ها با زیرنویس نچسبیده استفاده کنید. آزمایشی ویژگی آزمایشی فعال شود؟ در صفحه انتخاب فرمت کلیپ های ویدیویی بسازید بسیار از شما متشکرم! فرمت صوتی رسانه دانلود شده ای وجود ندارد به‌روزرسانی خودکار برای ساخت‌های %1$s در دسترس نیست. اگر %1$s را روی دستگاه خود نصب نکرده‌اید، یا می‌خواهید ویژگی‌های جدید آینده را در Seal پیش‌نمایش کنید، لطفاً %2$s را در نظر بگیرید. دانلودهایی که از این ویژگی استفاده می کنند به FFmpeg واگذار می شود تا بخش های انتخابی ویدیو را دانلود کند، این ویژگی همچنان آزمایشی است و برش کاملاً دقیق نخواهد بود، همه فرمت ها از این ویژگی پشتیبانی نمی کنند و ممکن است سرعت دانلود پایین تری را تجربه کنید. پیام از طرف توسعه دهنده تغییر به نسخه گیتهاب بسیار خب فهمیدم ویژگی در دسترس نیست پیشوند بدون وظایف دستوری سفارشی حامی مالی با حمایت مالی در GitHub از این برنامه حمایت کنید Seal همیشه رایگان و منبع باز برای همه خواهد بود. اگر دوست دارید، لطفاً در GitHub از من حمایت کنید! بازخورد حامیان دخیره فایل های موقت را در دایرکتوری داخلی خالی کردن بایگانی دانلود؟ ذخیره ویدیو به %1$d قسمت تقسیم خواهد شد خروجی به فایل برای ارسال اعلان در مورد وضعیت دانلودها، اپ به اجازهٔ شما نیاز دارد. انتخاب پوشه تقسیم ویدیو ترجیح فرمت MP4 (H.264) هنگامی که با اپ‌های دیگر به اشتراک گذاشته می‌شود دانلود ویدیو از نشانی حذف همیشگی %1$s از بایگانی؟ ویرایش «%1$s» وب‌سایت ناشناخته بیشتر بدانید تبدیل زیرنویس‌ها ترجیح فرمت AV1، VP9 یا H.265 هنگام تماشا در اپ‌های همگام نوع دانلود قدیمی بهه! مشکلی پیش آمد برای معین کردن پوشه، ضربه بزنید آغاز نام فایل‌ها رو محدود به کاراکترهای خاصی کنید تا از همگام بودن مطمئن شوید دانلود جدید کپی کن و خارج شو ضروری تبدیل زیرنویس‌ها به فرمتی دیگر نمایش همهٔ %1$d مورد قالب خروجی غیرفعال غیرفعال خودکار %d مورد %d مورد گسترش از پروکسی استفاده کن کیفیت محدودکردن نام فایل‌ها ویرایش فایل انتخابی به روزرسانی yt-dlp عنوان فهرست پخش بایگانی دانلود فعال کردن اعلان؟ پروکسی بر و رو رابط و تعامل yt-dlp یک ابزار دستور متنی برای دانلود ویدئو است. Seal، استفاده از yt-dlp را با کمک محیط گرافیک قابل‌فهم، دستورات مرسوم پیش‌فرض، و قابلیت‌های اضافی آسان می‌کند. \n \nSeal با کمک ساختن، ذخیره و اجرا قالب‌های دستوری مرسوم به صورت مستقیم، همانند ترمینال، به شما اجازه می‌دهد تا از قابلیت‌های پیشرفته بهره‌مند شوید. \n \nهنگام استفاده از دستورات، اکثر تنظیمات و قابلیت‌ها غیرفعال می‌شوند. دستورات فرمت ترجیحی تنظیمات پیش‌فرض جاسازی مِتا دیتا و پیش‌نمایش ویدئو در فایل صوتی جاسازی مِتا دیتا اجازه یکباره از مرتب‌سازی فرمت استفاده شود اجازه همیشگی اجازه نده دانلود با دیتا گوشی اجازه داده شود؟ دانلود شما ذخیره می‌شود در: فایل‌های زیرنویس نگهداری شوند قالب را برای نام‌های فایل خروجی مشخص کنید انتخاب قبلی استفاده شود برای دانلود بعدی بخاطر داشته باش تنظیمات سیستم ریست جستجو در زیرنویس‌ها نه ممنون فایل‌های دانلود شده وارد نمی‌شوند. شما باید آنها را دوباره به صورت دستی دانلود کنید تاریخچه دانلود وارد شود؟ تاریخچه دانلود خروجی گرفته شود؟ وارد کردن از تایخچه کپی فایل خروجی گرفته شود به نوع بَک‌آپ بَک‌آپ کامل هیچ‌ یک تاریخچه دانلود هر ماه هر هفته هر روز دانلود دوباره وارد کردن خارج کردن زبان‌های زیرنویس بروز شوند؟ زبان‌های زیر برای دانلودهای بعدی به اولویت شما اضافه می‌شوند: برای باز کردن صفحه وب برای تولید کوکی‌های جدید، ضربه بزنید: پوشه دستورات سفارشی پوشه خروجی را هنگام استفاده از دستورات سفارشی را مشخص کن همه زبان ها لیست پخش ادامه تنظیمات پیش فرض ترجیح دادن %1$s دانلود اتوماتیک با استفاده از تنظیمات فرمت تغییر تنظیمات پیش فرض دانلود بهترین فرمت موجود %d صدا %d صدا عملیات به صف اضافه شد اتصال همه از طریق IPv4 برای همیشه %1$s از قالب های دستورات پاک شود؟ از فرمت ها و زیرنویس ها انتخاب کنید و به دلخواه تنظیم کنید جستجو %d ویدئو %d ویدئو ترکیب چند صدا اجازه برای ترکیب کردن چند صدا به یک فایل سرصفحه کاربر مخزن ویدئو ریموکس ورود ویدئو های ریموکس به مخزن MKV برای پشتیبانی بهتر این ویدئو دانلود شد. اگر این غیر منتظره است، لطفا بایگانی دانلودتان را چک کنید. در حال استخراج %1$s از تاریخچه دانلود. فایل های دانلود شده و تنظیمات نسخه پشتیبانی نخواهند داشت. %1$d کوکی از %2$d وبسایت در کل ترجمه زیرنویس اتوماتیک زیرنویس با ترجمه اتوماتیک برای همه ی زبان ها در دسترس خواهند بود. این زیرنویس ها ممکن است اشتباه یا درک کردنشان دشوار باشد. زبان زیرنویس های فرمت اتوماتیک برای دانلود، جدا شده با ویرگول. برای تکراری نشدن دانلود ها، شناسه ویدئو های دانلود شده را درون بایگانی ضبط کنید مجبور استفاده از IPv4 %1$s به تاریخچه دانلود وارد شد جستجو در دانلود ها بارگذاری‌هایتان را اینجا پیدا خواهید کرد بارگذاری شد بر روی گزینه بارگذاری کلیک کنید یا لینک یک ویدئو را با این نرم‌افزار اشتراک گذاری فرمایید تا یک بارگذاری شروع شود همه از پیوندهای %1$d انتخاب فرمایید صف بارگذاری رفع اشکال ردیاب مشکل خطاهای رایج را حل و مشکلات شناخته شده را بررسی کنید نمایش نوار ناوبری پاک کن ادامه بده اطلاعات رسانه به خطایی برخوردید؟ قبل از گزارش یک مشکل جدید، لطفاً ردیاب مشکل ما را جستجو کنید. تاکنون بسیاری از مشکلات رایج در آنجا خطاب و مستند شده‌اند. ================================================ FILE: app/src/main/res/values-fil/strings.xml ================================================ I-save bilang audio I-save ang thumbnail Mga Setting Pangkahalatan, format, custom na command Download Hindi pwedeng walang laman ang link Folder ng video I-download at I-save ang audio, sa halip ng video I-save ang video thumbnail bilang isang file Hindi ma-download ang file Dina-download \"%1$s\" Hindi makuha ang impo ng video Kumukuha ng impo mula sa video… Tinanggihan ang permiso I-set ang wika ng pag-display May umiiral nang gawain ng pag-download na kasalukuyang tumatakbo I-paste ang URL Hindi magkatugma ang URL na mula sa clipboard Kanselahin Mga download Audio Buksan ang link Bumalik Mga credit Mga credit at libre software Kanselahin I-configure bago mag-download Mga reference sa paggamit ng Yt-dlp Format Limitahan ang kalidad ng video kapag marami itong naroroon Hindi tinukoy (default) I-convert Suriin at pamahalaan ang in-app na download, kasama ang mga video at audio na file. Tingnan ang mga setting ng pag-download at tiyaking mayroon kang pinakabagong bersyon ng yt-dlp bago ito gamitin. Tapos pindutin ang \"Download\" pagkatapos ma-ayos ang mga setting nito. I-download ang playlist Mag-download ng maraming video mula sa isang playlist Mangyaring i-set ang paggamit ng baterya ng app na ito sa \"Unrestricted\" (Hindi Pinaghihigpitan) sa mga setting ng system upang mag-download sa background. Mga opsiyon Karagdagang mga setting Abiso sa pag-download %d (na mga) thread na gagamitin sa pag-download DASH/HLS native video ng sabay-sabay. Hindi tugma ang URL mula sa naibahaging content Abiso ng mga na-download na file at progress Hanggang sa Directory ng download Pilliin kung saan ilagay ang mga video at audio file Hindi sinusuportahan ang mga directory sa labas ng Download/ at Documents/ Isalin Tumulong sa pag translate ng app na ito sa Hosted Weblate Hindi matukoy na error Wika ng pag-display Tapos na ang pag-download Alisin ang \"%1$s\" na mula sa iyong kasaysayan ng download nang tuluyan? Kumpirmahin Pangkahalatan Alisin\? Tanggalin Pindutin upang ma-install ang pinakabagong bersyon ng yt-dlp Tungkol rito Bersyon Bersyon ng Yt-dlp Sinuri Template ng command Tignan ang GitHub repository at ang README Video Huwag na magpakitang muli Isara Custom na command Patakbuhin ang yt-dlp command gamit ang custom na template I-edit Simulan ang magsasagawa ng command Detalyadong output Magpakita ng detalyadong mensahe kapag nagda-download Magpakita Madilim na tema Iba pa Sistema Naka-on Thumnail Ayusin itong download Walang nabago I-convert ang audio format Kalidad ng video Piniling format ng video Ang path ng output at URL ay idaragdag ng app. I-convert sa %1$s Pinakamahusay na kalidad Napiling format kapag marami ang naibinigay Buksan ang mga setting Pindutin ang \"I-paste\" upang makuha ang video link na mula sa iyong clipboard. Ang link ay nakopya sa clipboard Multi-threaded na pag-download Default Download Link ng video Abiso ng mga na-download na file at progress Tapos na ang pag-download. I-tap ito upang mabuksan. Mag-download ng higit pang mga bahagi ng M3U8/MPD na video nang magkasabay Kasalukuyang isinasagawa ang mga custom na command… Binabasa ang video link na mula sa naibahaging content… Magpakita ng higit pang mga aksyon Kinukuha ang impormasyon ng playlist… Pagpili ng playlist Tukuyin ang hanay ng mga video na ida-download mula sa playlist na \"%3$s\" (mula sa %1$d hanggang sa %2$d). Di-balidong index range Gumagamit ng pinakabagong bersyon ng yt-dlp Folder ng audio I-save sa subdirectory I-save ang mga file sa mga folder na may pangalan na katulad ng kanilang mga field Huwag pansinin ang pag-optimize ng baterya para ma-download ang app na ito sa background Hindi ma-install ang pinakabagong bersyon ng yt-dlp, siguraduhing naka-konekta sa internet. Tanggalin ang file Bersyon, mag feedback, awtomatikong pag-update Tumingin ng mga changelog at mga bagong bersyon I-configure ang pagsasaayos bago mag-download Nakopya ang ulat ng error sa clipboard I-paste Ang muling pag-encode ng mga audio file ay magdudulot ng pagkawala sa kalidad ng audio at pagtaas sa laki ng file. Download Gabay para sa paggamit Naka-off Pinakabagong release Madilim na tema, paiba-ibang kulay, wika Format ng video Dina-download ang playlist (%1$d/%2$d)… Mula sa Isyu sa pahintulot sa storage Nag da-download ang Seal… Pagsasaayos ng baterya Ang prefix I-restart Isinasagwa ang download… Laki ng video file Bagong template Buksan ang file Naka-queue %1$d (mga) video, %2$d (mga) audio file Kinukuha ang impormasyon Natapos na Kinansela ang gawain ng pag-download I-embed ang mga subtitle I-embed ang mga soft-sub sa mga video kung available Label Tanggalin\? Tanggalin ang \"%1$s\" mula sa mga template ng command nang tuluyan? Pagpili ng Template I-edit at pamahalaan ang mga template ng command Isyu sa GitHub Mag-submit ng isyu para sa bug report o feature request Kinopya ang impo sa clipboard Resolusyon ng video I-export sa clipboard I-import galing sa clipboard In-export ang %1$d (mga) template In-import ang %1$d (mga) template %1$d na mga gawain ng pag-download Dina-download Kinansela Na error Kopyahin ang link Kopyahin ang report Kamakailang Idinagdag Ibahagi Maraming salamat! Mensahe mula sa developer Baguhin ang pangalan Lahat Gamitin ang aria2c bilang external na downloader Gamitin ang Netscape Formatted na cookie para sa mga download Ang file na ito ay hindi na pwede o nawawala Kopyahin ang log I download ang subtitles Wika ng mga subtitle I off ang preview I crop ang artwork I crop ang image at gawin itong square Pagpili ng format Title ng video Gumamit Ng custom command Hindi pwede Gumamit ng cookies Huwag i-display ang thumbnail habang nag da-download Gumawa ng bagong cookies Alisin o im-marka ang mga segment sa mga video gamit ang SponsorBlock API Nabigong i-update sa pinakabagong bersyon Tukuyin ang kategorya ng SponsorBlock na aalisin o i-marka sa video file Mga kategorya ng SponsorBlock Awtomatikong tingnan ang pinakabagong bersyon sa GitHub Tingnan kung may update Ang bersyon na ito ay up to date Tangalin ang Temporary files Tanggalin ang lahat ng pansamantalang file mula sa pansamantalang direktoryo Maaaring gamitin ang pansamantalang mga file para magpatuloy sa mga nakanselong pag-download. Nais mo bang tanggalin ang lahat ng mga file na ito? \n \nMapupuntahan mo ang mga file na ito sa %1$s Maramihang Pagpili Private mode I-off ang download history Pinakamababang Quality Tangalin %1$d aytem(s) mula sa iyong download history para sa kabutihan\? Network Alisin ang entry na ito para sa \"%1$s\"? Mangyaring tandaan na ang mga cookies na naka-imbak para sa site na ito ay hindi matatanggal. Pribado na directory Ilagay ang mga downloads sa isang nakatagong direktoryo Tinanggal na ang %1$d temporary file(s) Cookies Video lamang (Walang audio) Update Ang pag-download gamit ang cellular network ay hindi pwede ayon sa iyong mga setting I-download gamit ang cellular Limitahan ang maksimum na bilis ng pag-download I-on ang eksperimento feature\? Segundo minuto Tangalin lahat ang mga Cookies I-update ang yt-dlp Hindi wastong input Iminungkahi Madilim na tema na may mataas na kontrast Pabago-bago ng kulay Piliin ang format na i-download bago magsimula ang pag-download Format ng file, kalidad ng video, mga subtitles Payagan ang pag-download ng media kapag nakakonekta sa mga metreng network Gumamit ng mga kulay mula sa mga wallpaper sa tema ng app Bersyon ng yt-dlp, abiso, playlist Limitahan ang dami, pampababa, mga cookies Pumili ng mga video na i-download mula sa playlist na \"%1$s\" %1$d ang napili Privacy Limitasyon sa bilis Pinakamataas na Bilis Maaaring hindi tama ang oras ng mga subtitle kapag tinanggal ang mga segment ng SponsorBlock. Ang iba sa mga pagpipilian ay hindi magagamit kapag gumagamit ng custom command I-download ang auto-generated captions Mabilis na pag-download I-edit ang custom shortcuts na pwedeng gamitin para gumawa ng command templates. Log Ipakita ang log I-apply Paano ito gumagana? i-edit ang shortcuts Inaayos ng mga format gamit ang -S na opsiyon ng yt-dlp Beta Channel ng telegram Ang pag-download mula sa ilang site ay nangangailangan ng impormasyon sa pagpapatunay ng account. I-click ang \"Bumuo ng bagong cookies\", at i-click ang URL ng website at pagkatapos ay mag-log in gamit ang iyong account sa pahina ng browser, bubuo ito ng app para sa iyo. Puwang ng matrix Folder ng SD card awtomatikong pag-caption Karamihan sa mga video streaming platform ay naghahatid ng audio at video nang hiwalay, maaari mong piliin at pagsamahin ang isang audio lamang na format na may isang video lamang na format sa isang video. sample na teksto ng tagalikha ng video Subtitle Mga shortcut %.2f na MB %.2f na GB Silipin Pamagat Mag-imbak ng mga pansamantalang file sa internal directory Isponsor Tanggalin ang lahat ng cookies na nakaimbak sa app para sa kabutihan? Hindi available ang auto-update para sa %1$s na mga build. Kung wala kang %1$s na naka-install sa iyong device, o gusto mong i-preview ang paparating na mga bagong feature sa Seal, mangyaring isaalang-alang ang %2$s. Lumipat sa mga build ng GitHub Paganahin ang automatic update Automatic ang update Itapon Wakas Video clip Simulan kalidad ng tunog Limitahan ang bitrate ng audio kapag maraming katangian ang naroroon Angkat Walang na-download na media Malinaw Pagpapatakbo ng mga gawain Upang mag-embed ng soft subtitles, ang mga video ay ire-remux sa mkv container. Maaaring gamitin ang VLC Media Player o iba pang compatible na app para manood ng mga video na may soft subtitles. Piniling format ng audio Pag-uuri ng format Suportahan ang app na ito sa pamamagitan ng pag-sponsor sa GitHub Puna Mga sponsor Gumawa ng mga video clip sa pahina ng pagpili ng format Idagdag Ang Seal ay palaging libre at open source para sa lahat. Kung gusto mo ito, mangyaring isaalang-alang ang pag-sponsor sa akin sa GitHub! Mga wika, mag-embed ng mga subtitle, at auto captions Matibay I-update ang channel Hindi limitado pinakamababang bitrate Format ng audio Mag-install ng mga pre-release na build para silipin ang mga bagong feature at pagbabago. \n \nMagkakaroon ng ilang kawalang-tatag sa mga bersyong ito, kaya mangyaring huwag mag-alinlangang magbigay sa amin ng feedback kung nakakaranas ka ng anumang mga problema upang matulungan kaming mapabuti ang app para sa hinaharap. Ide-delegate ang mga download gamit ang feature na ito sa FFmpeg para mag-download ng mga napiling seksyon ng video, experimental pa rin ang feature na ito at hindi magiging ganap na tumpak ang cutting, hindi lahat ng format ay sumusuporta sa feature na ito at maaari kang makaranas ng mas mabagal na bilis ng pag-download. I-edit ang file I-save Payagan ng isang beses Gamitin ang pagso-sort ng format Palaging payagan Huwag payagan Payagan ang pag-download gamit ang cellular? Panatilhin ang mga subtitle file Kailangan Ipakita ang lahat ng %1$d item I-embed ang metadata at thumbnail ng video sa audio file Ang iyong mga download ay maii-save bilang: Higpitan ang mga pangalan ng file Limitahan ang mga pangalan ng file sa mga partikular na character upang matiyak ang compatability Website Pamagat ng playlist Mga setting ng sistema Pilitin ang IPv4 Gawin ang lahat ng koneksyon gamit ang IPv4 Araw-araw Linggo-linggo Kada buwan I-remux ang mga video sa MKV container para sa mas mahusay na compatibility %d na item Mga %d na item %1$d na mga cookie mula sa %2$d na website sa kabuuan I Download ang video gamit ang URL Sige Hindi pinagana Nakuha ko Maghanap sa mga download Ang mga Auto-translated na subtitle para sa lahat ng wika ay magiging available sa mga download. Maaaring hindi tama at mahirap intindihin ang mga subtitle na ito. Kasaysayan ng download Alisin ang %1$s mula sa mga template ng command para nang tuluyan? I-tap para buksan ang webpage para sa pagbuo ng bagong cookie: I-embed ang metadata pamana Kalidad Paganahin ang mga abiso? Laging gamitin ang MP4(H.264) na format para sa pagbabahagi sa iba pang app Pagsamahin ang maramihang audio stream Payagan ang maramihang mga audio stream na i-merge sa isang file Hindi available ang feature Hatiin ang video Walang custom na command task I-convert ang mga subtitle I-convert ang mga subtitle sa ibang format Hahatiin ang video sa %1$d na mga kabanata Palawakin Bagong gawain sa pag-download Header ng User-Agent I-edit ang \"%1$s\" Proxy Kailangan ng app ang iyong pahintulot na mag-post ng mga abiso tungkol sa status ng pag-download at progress. Huwag paganahin I-tap para mag-set up ng directory Custom na directory ng command Tagapili ng folder Tukuyin ang directory ng output kapag gumagamit ng mga custom na command Custom Awto Mga command I-remux ang video container Ang video ay na-download na. Kung ito ay hindi ang inaasahan mong resulta, mangyaring tingnan ang iyong archive ng mga download. I-export File Clipboard I-export sa file Ini-export ang %1$s mula sa kasaysayan ng download. Hindi maba-back up ang mga na-download na file at ang kagustuhan mo. Uri ng pag-download Ang yt-dlp ay isang mahusay na command-line tool para sa pag-download ng mga video. Pinapadali ng Seal ang paggamit ng yt-dlp sa pamamagitan ng pagbibigay ng intuitive na GUI, mga preset para sa mga karaniwang command, at iba pang karagdagang features. \n \nPara sa advanced na paggamit ng yt-dlp, pinapayagan ka ng Seal na lumikha, mag-save at magsagawa ng mga custom na template ng command nang direkta, tulad ng sa isang terminal. \n \nKapag gumagamit ng mga custom na command, karamihan sa mga opsyon at feature ng GUI ay hindi papaganahin. Ay Naku! Nagkaoon ng problema Kopyahin at lumabas Simulan Gumamit ng proxy para sa mga koneksyon sa internet Laging gamitin ang AV1, VP9 o H.265 na mga format para sa panonood sa mga compatible na app Kagustuhan sa format Matuto pa Hindi alam Output ng template Mga preset Tukuyin ang template para sa pangalan ng mga output file Mga subtitle na awtomatikong isinalin Wika ng mga subtitle na ida-download sa pagpili ng Awto format, na pinaghihiwalay ng mga kuwit (comma). Tandaan para sa susunod na pag-download I-reset Maghanap sa mga subtitle Salamat nalang Gamitin ang nakaraang pagpili Wala Ang mga sumusunod na wika ay idadagdag sa iyong kagustuhan para sa pag-download sa hinaharap: I-update ang mga wika ng subtitle? I-import Buong backup Uri ng backup I-export sa I-import mula sa I-export ang kasaysayan ng download? Mag-import ng kasaysayan ng download? Hindi mai-import ang mga na-download na file. Kakailanganin mong i-download muli ang mga ito nang manu-mano Archive ng mga download Linisin ang archive ng mga download? I-record ang mga na-download na video ID sa isang archive para maiwasan ang mga nadobleng download Alisin ang %1$s sa archive file nang tuluyan? Nag-import ng %1$s sa kasaysayan ng download Muling i-download Paghahanap Lahat ng mga wika Ang preset Piliin ang %1$s Awtomatikong mag-download gamit ang iyong mga kagustuhan sa format %d na audio %d na mga audio Ang gawain ay nadagdag sa pila Playlist Magpatuloy Pumili mula sa mga format, subtitle, at lalong i-customize I-edit ang preset I-download ang pinakamahusay na magagamit na format %d na video %d na mga video Makikita mo ang iyong mga download dito I-tap ang button sa pag-download o magbahagi ng link ng video sa app na ito para magsimula ng pag-download Nai-download na Lahat Queue ng pag-download Pumili mula sa %1$d na mga link Ipakita ang navigation drawer Ipagpatuloy Tanggalin Impo ng media Mag-troubleshoot Ang issue tracker Ayusin ang mga karaniwang error at suriin para sa mga kilalang isyu Nagkaroon ba ng error? Bago mag-ulat ng bagong isyu, maaring hanapin ang aming issue tracker. Maraming karaniwang problema ang natugunan at naidokumento na doon. Na-save na mga link Magdagdag ng bagong link Idagdag sa %1$s ================================================ FILE: app/src/main/res/values-fr/strings.xml ================================================ Dossier vidéo Enregistrer en tant qu\'audio Enregistrer la miniature Paramètres Télécharger Télécharger et enregistrer des fichiers audio, au lieu de vidéos yt-dlp est à jour Permission refusée Téléchargement terminé Impossible de télécharger le fichier Général Langue d\'affichage Version de yt-dlp Cliquez pour installer la dernière version de yt-dlp Retirer ? Confirmer Annuler Téléchargements Supprimer le fichier Enregistrer la miniature de la vidéo dans un fichier Audio Dernière version Vidéo Vérifié Crédits Crédits et logiciels libres Commande personnalisée Modèle de commande Modifier Commencer à exécuter la commande Affichage Thème sombre, couleur dynamique, langues Thème sombre Système Activé Désactivé Annuler Configurer avant le téléchargement Configurer les préférences avant le téléchargement Ajuster ce téléchargement Général, format, commande personnalisée Le lien ne peut pas être vide Téléchargement de « %1$s » Récupération des informations vidéo… Lien copié dans le presse-papiers Retour Impossible d\'installer la dernière version de yt-dlp. Veuillez vous assurer que vous êtes connecté à Internet. Impossible de récupérer les informations de la vidéo Définir la langue d\'affichage Impossible d\'identifier l\'URL dans le presse-papiers Ouvrir le lien Une tâche de téléchargement est déjà en cours d\'exécution Coller l\'URL Supprimer définitivement « %1$s » de l\'historique de vos téléchargements ? Retirer Sortie détaillée À propos Version, remarques, mise à jour automatique Consulter le dépôt GitHub et le fichier README Avancé Afficher des messages détaillés lors du téléchargement Version Rechercher les journaux de modifications et les nouvelles versions Exécuter la commande yt-dlp avec un modèle personnalisé Coller Télécharger Références d\'utilisation de Yt-dlp Le répertoire de sortie et l\'URL seront ajoutés par l\'application. Convertir en %1$s Qualité de la vidéo Format vidéo préféré Par défaut Non spécifié (par défaut) Guide de l\'utilisateur Téléchargement de la liste de lecture (%1$d/%2$d)… Format préféré lorsque plusieurs sont fournis Ne plus afficher Vérifiez et gérez les téléchargements dans l\'appli, y compris les vidéos et les fichiers audio. Rapport d\'erreur copié dans le presse-papiers Miniature Non converti Format Format vidéo Convertir Télécharger Fermer Cliquez sur « Coller » pour obtenir le lien vidéo à partir de votre presse-papiers. Cliquez ensuite sur « Télécharger » après avoir réglé ses paramètres. Consultez les paramètres de téléchargement et assurez-vous que vous disposez de la dernière version de yt-dlp avant de l\'utiliser. Télécharger la liste de lecture Télécharger plusieurs vidéos à partir d\'une liste de lecture Options Paramètres supplémentaires Impossible de faire correspondre l\'URL au contenu partagé Lecture d\'un lien vidéo à partir d\'un contenu partagé… Afficher plus d\'actions Exécution de commandes personnalisées… Veuillez régler le paramètre d\'utilisation de la batterie de cette application sur « Sans restriction » dans les paramètres système afin de pouvoir télécharger en arrière-plan. Téléchargement multi-thread Télécharger plus de parties de vidéos M3U8/MPD en parallèle %d processus seraient utilisés pour télécharger simultanément des vidéos natives DASH/HLS. Enregistrer dans un sous-répertoire Enregistrer les fichiers dans des dossiers portant le nom des champs respectifs Dossier audio Répertoire de téléchargement Sélectionner où stocker les fichiers vidéo et audio Configuration de la batterie Problème de permission de stockage Les répertoires autres que Download/ et Documents/ ne sont pas pris en charge Ignorer l\'optimisation de la batterie pour les téléchargements en arrière-plan Seal est en cours de téléchargement… Erreur inconnue Lien vidéo Téléchargement terminé. Appuyez pour ouvrir. Notification de téléchargement Notification des fichiers téléchargés et de leur progression Recherche d\'informations sur la liste de lecture… Sélection de la liste de lecture Spécifiez la plage de vidéos à télécharger à partir de la liste de lecture « %3$s » (de %1$d à %2$d). Début Fin Plage d\'index invalide Notification des fichiers téléchargés et de leur progression Convertir le format audio Le réencodage des fichiers audio entraîne une perte de qualité audio et une augmentation de la taille du fichier. Meilleure qualité Limiter la qualité lorsque plusieurs vidéos sont présentes Ouvrir les paramètres Traduire Aidez à traduire cette application sur Hosted Weblate Préfixe Sous-titres intégrés Intégrer les sous-titres dans les vidéos, si disponibles Sélection du modèle Téléchargement en cours… Soumettre un ticket pour signaler un bug ou suggérer une fonctionnalité Nouveau modèle Étiquette Retirer ? Retirer définitivement « %1$s » des modèles de commande \? Modifier et gérer les modèles de commande Téléchargement annulé Ticket GitHub Ouvrir le fichier Redémarrer Terminé Récupération des infos Erreur Copier le lien Rapport de copie En file d\'attente Téléchargement en cours Annulé Information copiée dans le presse papier Résolution vidéo Taille du fichier vidéo Rechercher les mises à jour Utiliser des cookies au format Netscape pour les téléchargements Effacer les fichiers temporaires Supprimer tous les fichiers temporaires du répertoire temporaire %1$d fichier(s) temporaire(s) supprimé(s) Les fichiers temporaires peuvent être utilisés pour reprendre les téléchargements annulés. Voulez vous vraiment supprimer tous ces fichiers ? \n \nVous pouvez accéder à ces fichiers dans %1$s La version actuelle est à jour Récemment ajouté Échec de la mise à jour vers la dernière version Mettre à jour Utilisez aria2c comme gestionnaire de téléchargement externe %1$d fichier(s) vidéo, %2$d fichier(s) audio Vérifier automatiquement la dernière version sur GitHub Exporter vers le Presse-papier %1$d modèle(s) exporté(s) %1$d tâches de téléchargement Importer à partir du Presse-papier Supprimer définitivement le(s) élément(s) %1$d de votre historique de téléchargement ? %1$d modèle(s) importé(s) Supprimer ou marquer des segments de vidéos avec l\'API SponsorBlock Spécifiez les catégories SponsorBlock à supprimer ou marquer dans le fichier vidéo Catégories SponsorBlock Mode multi-sélection Désactiver l\'historique des téléchargements Incognito Télécharger via le réseau mobile Autoriser le téléchargement sur une connexion limitée Couleur dynamique Vos paramètres empêchent le téléchargement sur un réseau mobile Appliquez les couleurs des fonds d\'écran au thème de l\'application Ce fichier n\'est plus disponible Réseau Limite de débit Limiter le débit maximal de téléchargement Débit maximal Thème sombre à fort contraste Entrée invalide Qualité la plus basse Limite de débit, téléchargeur, cookies Répertoire privé Indisponible Format de fichier, qualité vidéo, sous-titres Version Yt-dlp, notification, liste de lecture Désactiver l’aperçu Désactiver les images d\'aperçu pendant le téléchargement Utiliser une commande personnalisée Vie privée Rogner l\'image Rogner l\'image intégrée dans un carré Stocker les téléchargements dans un répertoire caché Tout sélectionner Sélectionner les vidéos à télécharger à partir de la liste de lecture « %1$s » %1$d sélectionné(s) Suggéré Vidéo (sans audio) Sélection du format Sélectionnez le format à télécharger avant de commencer le téléchargement Générer de nouveaux cookies Cookies Le téléchargement à partir de certains sites nécessite des informations d\'authentification du compte. Cliquez sur « Générer de nouveaux cookies », saisissez l\'URL du site web, puis connectez-vous avec votre compte dans la page du navigateur, l\'application les générera pour vous. Canal Telegram Utiliser les cookies Retirer cette entrée pour « %1$s » ? Veuillez noter que les cookies stockés pour ce site ne seront pas effacés. Certaines options ne sont pas disponibles lors de l\'utilisation de commande(s) personnalisée(s) Comment ça fonctionne ? Espace Matrix La plupart des plateformes de streaming vidéo fournissent l\'audio et la vidéo séparément. Vous pouvez sélectionner et fusionner un format uniquement audio avec un format uniquement vidéo pour obtenir une seule vidéo. Dossier de la carte SD Sous-titres automatiques Télécharger les sous-titres générés automatiquement Texte type du créateur vidéo Langues des sous-titres Effacer Modifier les raccourcis Ajouter Raccourcis Modifier les raccourcis personnalisés qui peuvent être utilisés pour composer des modèles de commande. Tâches courantes Afficher le journal Journal Télécharger les sous-titres Téléchargement rapide Titre de la vidéo, exemple de texte Sous-titre Copier le journal Langues, sous-titres intégrés, sous-titres automatiques Les sous-titres peuvent être mal synchronisés lors de la suppression des segments du SponsorBlock. Pour intégrer des sous-titres, les vidéos seront remixées dans un conteneur mkv. Vous pouvez utiliser VLC Media Player ou d\'autres applications compatibles pour regarder des vidéos avec des sous-titres intégrés. %.2f GB %.2f M Format audio préféré Importer Titre Tri des formats avec l\'option -S de yt-dlp Format audio Aucun média téléchargé Bêta Activer la fonction expérimentale ? Réaliser des clips vidéo dans la page de sélection du format Stable Partager Installez les préversions pour avoir un aperçu des nouvelles fonctionnalités et des modifications. \n \nIl y aura une certaine instabilité dans ces versions, alors n\'hésitez pas à nous faire part de vos remarques si vous rencontrez des problèmes afin de nous aider à améliorer l\'application à l\'avenir. seconde Activer la mise à jour automatique Limiter le débit binaire audio lorsque plusieurs qualités sont présentes Illimité Qualité audio Tri des formats Renommer Les téléchargements utilisant cette fonction seront délégués à FFmpeg pour télécharger des sections sélectionnées de la vidéo, cette fonction est encore expérimentale et la découpe ne sera pas complètement précise, tous les formats ne prennent pas cette fonction et vous pouvez constater des vitesses de téléchargement plus lentes. Appliquer Aperçu Rejeter Couper la vidéo Canal de mise à jour Mise à jour automatique Fin Débit binaire le plus faible Début minute Effacer tous les cookies Supprimer définitivement tous les cookies stockés dans l\'application ? Sponsor Soutenez cette application en la parrainant sur GitHub Seal sera toujours libre et gratuit pour tout le monde. Si vous l\'aimez, pensez à me parrainer sur GitHub ! Remarques Sponsors Stocker les fichiers temporaires dans le répertoire interne D\'accord Compris Fonctionnalité non disponible Aucune tâche de commande personnalisée La mise à jour automatique n\'est pas disponible pour les versions %1$s. Si vous n\'avez pas installé %1$s sur votre appareil, ou si vous souhaitez avoir un aperçu des nouvelles fonctionnalités de Seal, veuillez consulter %2$s. passer aux moutures GitHub Message du développeur Merci beaucoup ! Convertir les sous-titres Convertir les sous-titres dans un autre format Séparer la vidéo La vidéo sera divisée en %1$d chapitres Oups ! Quelque chose s\'est mal passé Copier et quitter Télécharger des vidéos depuis l\'URL Développer Modifier « %1$s » Nouvelle tâche de téléchargement Commencer yt-dlp est un puissant outil en ligne de commande pour le téléchargement de vidéos. Seal facilite l\'utilisation de yt-dlp en fournissant une interface graphique intuitive, des préréglages pour les commandes courantes et d\'autres fonctionnalités supplémentaires. \n \nPour une utilisation avancée de yt-dlp, Seal vous permet de créer, de sauvegarder et d\'exécuter des modèles de commandes personnalisées directement, comme dans un terminal. \n \nLors de l\'utilisation de commandes personnalisées, la plupart des options et fonctionnalités de l\'interface graphique sont désactivées. Exporter vers un fichier L\'application a besoin de votre autorisation pour afficher des notifications sur l\'état et la progression du téléchargement. Sélecteur de dossiers Préférez les formats MP4 (H.264) pour partager avec d\'autres applications Commandes Inconnu En savoir plus Préférez les formats AV1, VP9 ou H.265 pour la visualisation dans les applications compatibles Type de téléchargement Répertoire des commandes personnalisées Spécifier le répertoire de sortie lors de l\'utilisation de commandes personnalisées Appuyer pour configurer le répertoire En-tête User-Agent Préférence de format Spécifier le modèle pour les noms de fichiers de sortie Désactiver Automatique Utiliser un proxy pour les connexions internet Qualité Touchez pour ouvrir la page web permettant de générer de nouveaux cookies : Mettre à jour yt-dlp Supprimer définitivement %1$s des modèles de commande ? Activer les notifications ? Proxy Effacer l\'archive de téléchargement ? Supprimer %1$s dans le fichier d\'archive pour de bon ? Hérité Intégrer les métadonnées Préréglages Requis Modèle de fichier de sortie Désactivé Enregistrer les ID des vidéos téléchargées dans une archive pour éviter les doublons de téléchargement Personnalisé Intégrer les métadonnées et la vignette de la vidéo dans le fichier audio Archive de téléchargement Sauvegarder Utiliser le tri par format Montrer tous les %1$d éléments %d élément %d d\'éléments %d éléments Modifier le fichier Toujours autoriser Ne pas autoriser Autoriser l\'utilisation des données mobiles ? Combiner plusieurs pistes audios Autoriser plusieurs pistes audio a être fusionnées en un seule fichier Vos téléchargements seront enregistrés dans : Garder le fichier des sous-titres Limiter les noms de fichiers à certains caractères pour assurer la compatibilité Site web Titre de la playlist Sous-titres automatiquement traduits Se souvenir pour le prochain téléchargement Paramètres système Forcer en IPv4 Établit toutes les connexions en IPv4 Rechercher dans les téléchargements Rechercher Des sous-titres automatiquement traduits seront disponibles pour toutes les langues dans les téléchargements. Ces sous-titres peuvent être imprécis et difficiles à comprendre. Aucun Non merci Autoriser une fois Importer Les fichiers téléchargés ne seront pas importer , vous devrais lea télécharger manuellement La langue des sous-titres a télécharger en auto-sélection de format, séparée par des virgules. Rechercher dans sous-titres Interface et interactions Utiliser la selection précédante Fichier Presse-papier Importer l\'historique de téléchargement ? Les langues suivantes vont être ajoutées a vos préférences pour les prochains téléchargements : Mettre à jour les langues des sous-titres ? Restreindre les noms de fichiers La vidéo a été téléchargée. Si ce n\'est pas le comportement attendu, veuillez vérifier votre archive téléchargée. Exportation de %1$s depuis l\'historique de téléchargement. Les fichiers téléchargés et les préférences ne seront pas sauvegardés. Exporter Sauvegarde complète Type de sauvegarde Réinitialiser Exporter vers Importer depuis Exporter l\'historique des téléchargements ? Historique des téléchargements Importation de %1$s dans l\'historique des téléchargements Retélécharger Couleur et style %1$d cookies de %2$d sites web au total Remuxez les vidéos dans un conteneur MKV pour une meilleure compatibilité Conteneur vidéo Remux Tous les jours Toutes les semaines Chaque mois Toutes les langues Préréglage Préférer %1$s Télécharger automatiquement en fonction de vos préférences de format Modifier le préréglage %d vidéo %d de vidéos %d vidéos %d fichier audio %d de fichiers audio %d fichiers audio Tâche ajoutée à la file Choix pour les formats, les sous-titres et d\'autres customisations Liste de lecture Continuer Télécharger le meilleur format disponible Vous trouverez vos téléchargements ici Cliquez sur le bouton Télécharger ou partagez un lien vidéo vers cette application pour démarrer un téléchargement Téléchargé File d\'attente de téléchargements Tout Sélectionner à partir de %1$d liens Afficher le tiroir de navigation Continuer Supprimer Infos média Vous avez rencontré une erreur ? Avant de signaler un nouveau problème, veuillez consulter notre outil de suivi des problèmes. De nombreux problèmes courants y ont déjà été traités et documentés. Dépannage Suivi des problèmes Corriger les erreurs courantes et vérifier les problèmes connus ================================================ FILE: app/src/main/res/values-gl/strings.xml ================================================ Cartafol dos videos Gardar como arquivo de sonido Gardar carátula Descarga e garda como audio en vez de vídeo Gardar a carátula do vídeo como un arquivo Estás a usar a última versión de yt-dlp Non se pudo descargar a última versión de yt-dlp. Por favor, asegúrese de que está conectado ao internet. Recibindo información do vídeo… Permiso denegado Axustes Descargar Descarga finalizada Xeral, formatos e comandos personalizados Non se puido descargar o arquivo O enlace non pode estar vacío Eliminar Cancelar Avanzado Pechar Saída detallada Guía de usuario Preferencia de formato de vídeo Enlace copiado ao portapapeis Créditos e software de código aberto Vexa repositorio de GitHub e README Eliminar Executar un comando de yt-dlp cun modelo personalizado Fai clic para actualizar a libraría yt-dlp Non se puido pegar a URL dende o portapapeis Formato preferido cando se facilitan varios Faga clic no botón de “Pegar” para obter o enlace dende o seu portapapeis. Activado Referencias de uso para yt-dlp Editar Pegar URL dende o portapapeis Converter Calidade do vídeo Modo escuro, cores dinámicas, lingua Configurar axustes antes de descargar Procurar actualizacións e historial de cambios Versión de yt-dlp Aspecto Desactivado Descargar O erro reportado foi copiado ao portapapeis Atrás Descargas Versión máis recente Versión Xeral Mostrar en pantalla mensaxes relevantes durante a descarga Abrir axustes Formato de vídeo Converter formato de audio Limitar a calidade do vídeo se houbese varios Pegar Está seguro/a de eliminar «%1$s» do seu historial de descargas\? Iniciar a execución do comando Verificado Acerca de Abrir enlace A recodificación de arquivos de audio causará perda de calidade de audio e aumento do tamaño do arquivo. Non converter Versión, comentarios, actualización automática Miniatura Modelo de comando Descargando “%1$s” Tema escuro Cancelar Non mostrar de novo Configurar antes da descarga Confirmar Escoller lingua Sistema Eliminar\? Lingua %d elemento %d elementos Comando personalizado Xa se está realizando unha descarga Configurar axustes para esta descarga Vídeo Houbo un erro ao obter a información do vídeo Sen especificar (por defecto) Converter a %1$s Formato Actualizar yt-dlp Audio O directorio de saída e a URL serán engadidas pola app automaticamente. Créditos Mellor calidade ================================================ FILE: app/src/main/res/values-hi/strings.xml ================================================ वीडियो फ़ोल्डर ऑडियो के रूप में सहेजें थंबनेल सहेजें सेटिंग्स सामान्य, प्रारूप, कस्टम कमांड डाउनलोड लिंक खाली नहीं हो सकता डाउनलोड समाप्त फ़ाइल डाउनलोड नहीं कर सका \"%1$s\" डाउनलोड करें सामान्य भाषा प्रदर्शित करें प्रदर्शन भाषा सेट करें URL पेस्ट करें क्लिपबोर्ड में URL से मेल नहीं खा सका Yt-dlp संस्करण हटाएं\? अपने डाउनलोड इतिहास से \"%1$s\" को हमेशा के लिए हटा दें\? रद्द करें डाउनलोड ऑडियो लिंक को क्लिपबोर्ड पर कॉपी किया गया हटाएं फाईल मिटाएं हमारे बारे में संस्करण, प्रतिक्रिया, ऑटो अपडेट पीछे संस्करण चैंज और नए संस्करणों की तलाश करें नवीनतम रिलीज़ GitHub रिपॉजिटरी और README की जाँच करें वीडियो संपादित करें कमांड निष्पादित करना शुरू करें विकसित विस्तृत आउटपुट डाउनलोड करते समय विस्तृत संदेश प्रिंट करें दिखाएँ डार्क थीम, गतिशील रंग, भाषाएं डार्क थीम सिस्टम चालू बंद रद्द करें डाउनलोड करने से पहले सरंचना करें इस डाउनलोड को समायोजित करें त्रुटि की जानकारी सूचि मैं उतारा थंमनेल चिपकाएं आउटपुट पथ और URL ऐप द्वारा जोड़ा जाएगा। ध्वनि प्रारूप को परिवर्तित करें हाई कंट्रास्ट डार्क थीम अमान्य इनपुट निम्नतम गुणवत्ता वीडियो के बजाय ऑडियो डाउनलोड और सेव करें अनुमति नहीं मिली वीडियो थंबनेल को फ़ाइल के रूप में सहेजें yt-dlp के नवीनतम संस्करण का उपयोग करना नवीनतम yt-dlp संस्करण स्थापित करने के लिए क्लिक करें नवीनतम yt-dlp संस्करण स्थापित नहीं किया जा सका। आप इंटरनेट से जुडे हैं कृपया यह सुनिश्चित कीजिए। वीडियो जानकारी ली जा रही है… एक मौजूदा डाउनलोड कार्य पहले से चल रहा है लिंक खोलें वीडियो जानकारी नहीं ली जा सकी पुष्टि करें चेक किए गए क्रेडिट कस्टम टेम्पलेट के साथ yt-dlp कमांड चलाएँ क्रेडिट और लिब्रे सॉफ्टवेयर कस्टम कमांड कमांड टेम्पलेट डाउनलोड करने से पहले प्राथमिकताएं कॉन्फ़िगर करें Yt-dlp उपयोग संदर्भ असंशोधित %1$s में बदलें प्रारूप ध्वनि फ़ाइलों को पुनः एनकोड करने से ध्वनि गुणवत्ता को हानि होगी और फ़ाइल का आकार बढ़ेगा। चलचित्र गुणवत्ता एकाधिक मौजूद होने पर चलचित्र की गुणवत्ता सीमित करें अनिर्दिष्ट (मूल) पसंदीदा चलचित्र प्रारूप एकाधिक विकल्प दिए जाने पर पसंदीदा प्रारूप चलचित्र प्रारूप संशोध डाउनलोड बंद करें दोबारा ना दिखाएं उपयोगकर्ता मार्गदर्शिका श्रेष्ठ गुणवत्ता समायोजन खोलें सूचि से चलचित्र लिंक प्राप्त करने के लिए \"चिपकाएं\" दबाएं। समायोजन के बाद \"डाउनलोड\" दबाएं। सभी का चयन करे %1$d चुने चलचित्र और ध्वनि फाइलों सहित ऐप में डाउनलोड की जांच और प्रबंधन करें। डाउनलोड समायोजन देखें और उपयोग से पहले सुनिश्चित करें कि आपके पास yt-dlp का नवीनतम संस्करण है। मूल डाउनलोड डाउनलोड की गई फ़ाइलें और प्रगति की सूचना दें सूची डाउनलोड करें सूची से कई चलचित्र डाउनलोड करें चलचित्र लिंक डाउनलोड समाप्त। खोलने के स्पर्श करें। अनुकूलित आदेश चल रहा है… कृपया पृष्ठभूमि में डाउनलोड करने के लिए सिस्टम सेटिंग्स में इस ऐप के बैटरी उपयोग को \"अप्रतिबंधित\" पर सेट करें। मल्टी थ्रेडेड डाउनलोड समानांतर में M3U8/MPD वीडियो के और हिस्से डाउनलोड करें विकल्प अतिरिक्त सेटिंग्स साझा सामग्री से URL का मिलान करने में असमर्थ साझा की गई सामग्री से वीडियो लिंक पढ़ना… और कार्रवाइयाँ दिखाएँ डाउनलोड अधिसूचना डाउनलोड की गई फ़ाइलों और प्रगति की सूचना दें प्लेलिस्ट जानकारी ला रहा है… प्लेलिस्ट चयन प्लेलिस्ट \"%3$s\" ( %1$d से %2$d तक) से डाउनलोड करने के लिए वीडियो की श्रेणी निर्दिष्ट करें। शुरू समाप्त अमान्य अनुक्रमणिका श्रेणी प्लेलिस्ट (%1$d/%2$d) डाउनलोड हो रही है… ऑडियो फ़ोल्डर डाउनलोड निर्देशिका उपनिर्देशिका में सहेजें फ़ाइलों को संबंधित फ़ील्ड के नाम वाले फ़ोल्डरों में सहेजें स्टोरेज अनुमति मुद्दा डाउनलोड/ और दस्तावेज़/ के बाहर निर्देशिकाएँ समर्थित नहीं हैं बैटरी संरचना सील डाउनलोड कर रही है… अज्ञात त्रुटि अनुवाद करें पथ टेम्पलेट सबटाइटल एम्बेड करें यदि उपलब्ध हो तो वीडियोज में सॉफ्ट सबटाइटल एम्बेड करें नया टेम्पलेट लेबल हटाना है\? कमांड टेम्प्लेट से \"%1$s\" हमेशा के लिए हटा दें\? टेम्पलेट चयन कमांड टेम्प्लेट संपादित और प्रबंधित करें डाउनलोड प्रगति पर है… डाउनलोड कार्य रद्द किया गया बग रिपोर्ट या सुविधा अनुरोध के लिए कोई समस्या सबमिट करें गिटहब अनुरोध जानकारी क्लिपबोर्ड पर कॉपी की गई कतारबद्ध हुआ पूरा हुआ डाउनलोड रद्द जानकारी लाई जा रही है फाइल खोलें पुनर्प्रारंभ करें त्रुटि संदेश लिंक को कॉपी करें रिपोर्ट कापी करें वीडियो रेजोल्यूशन वीडियो फ़ाइल आकार क्लिपबोर्ड पर निर्यात करें क्लिपबोर्ड से आयात करें निर्यात किए %1$d टेम्प्लेट आयात हुए %1$d टेम्प्लेट %1$d डाउनलोड कार्य हाल ही में जोड़ा %1$d वीडियो, %2$d ऑडियो फ़ाइल (फ़ाइलें) अपने डाउनलोड इतिहास से %1$d आइटम (आइटमों) को हमेशा के लिए निकाल दें\? स्पांसर-ब्लॉक API के साथ वीडियो में सेगमेंट निकालें या चिह्नित करें स्पांसर-ब्लॉक श्रेणियां वीडियो फ़ाइल में निकालने या चिह्नित करने के लिए स्पांसर-ब्लॉक श्रेणियाँ निर्दिष्ट करें अपडेटस के लिए जाँच करें गिटहब पर स्वचालित रूप से नवीनतम संस्करण की जाँच करें वर्तमान संस्करण अप टू डेट है नवीनतम संस्करण में अपडेट करने में विफल अपडेट करें डाउनलोड के लिए नेटस्केप स्वरूपित कुकीज़ का प्रयोग करें अस्थायी फ़ाइलें साफ़ करें अस्थायी निर्देशिका से सभी अस्थायी फ़ाइलें हटाएं हटाई गई %1$d अस्थायी फ़ाइल(फ़ाइलें) रद्द किए गए डाउनलोड को फिर से शुरू करने के लिए अस्थायी फ़ाइलों का उपयोग किया जा सकता है। क्या आप वाकई इन सभी फ़ाइलों को मिटाना चाहते हैं\? \n \nआप इन फ़ाइलों को %1$s में एक्सेस कर सकते हैं बहुचयन मोड गुमनाम गतिशील रंग मोबाइल डेटा का उपयोग कर डाउनलोड करें आपकी सेटिंग के अनुसार सेल्युलर नेटवर्क से डाउनलोड करना अक्षम किया है यह फाइल अब उपलब्ध नही है अनुपलब्ध फ़ाइल प्रारूप, वीडियो क्वालिटी, सबटाइटल दर सीमा, डाउनलोडर, कुकीज़ पूर्वावलोकन अक्षम करें गोपनीयता कस्टम कमांड का प्रयोग करें निजी निर्देशिका डाउनलोडस को एक हिडन डायरेक्टरी में स्टोर करें छवि को वर्गाकार में क्रॉप करें कलाकृति क्रॉप करें नेटवर्क दर सीमा अधिकतम दर %d धाराओं का उपयोग DASH/HLS नेटिव वीडियो को समवर्ती रूप से डाउनलोड करने के लिए किया जाएगा। चुनें कि वीडियो और ऑडियो फ़ाइलें कहाँ संग्रहित करनी हैं इस ऐप को पृष्ठभूमि में डाउनलोड करने के लिए बैटरी ऑप्टिमाइज़ेशन को इग्नोर करें होस्ट किए गए वेबलेट पर इस ऐप का अनुवाद करने में सहायता करें बाहरी डाउनलोडर के रूप में aria2c का प्रयोग करें डाउनलोड इतिहास अक्षम करें वॉलपेपर से ऐप थीम पर रंग लागू करें मीटर्ड नेटवर्क पर मीडिया को डाउनलोड करने की अनुमति दें अधिकतम डाउनलोड गति सीमित करें Yt-dlp वर्शन, सूचना, प्लेलिस्ट डाउनलोड के समय थंमनेल ना दिखाएं प्लेलिस्ट \"%1$s\" से डाउनलोड करने के लिए वीडियो चुनें वीडियो (कोई ऑडियो नहीं) सुझावित नई कुकीज़ उत्पन्न करें डाउनलोड शुरू करने से पहले डाउनलोड करने के लिए प्रारूप का चयन करें प्रारूप चयन कुकीज़ का प्रयोग करें कस्टम आदेश का उपयोग करते समय कुछ विकल्प अनुपलब्ध होते हैं यह कैसे काम करता है\? टेलीग्राम चैनल कुछ साइटों से डाउनलोड करने के लिए खाता प्रमाणीकरण जानकारी की आवश्यकता होती है। \"नई कुकी जनरेट करें\" पर क्लिक करें, वेबसाइट का URL दर्ज करें और फिर ब्राउज़र पेज में अपने खाते से लॉग इन करें, ऐप इसे आपके लिए जनरेट कर देगा। \"%1$s\" के लिए यह एंट्री हटाएँ? कृपया ध्यान दें कि इस साइट के लिए संग्रहित कुकीज़ साफ़ नहीं की जाएंगी। कुकीज़ मैट्रिक्स स्पेस जोड़ें शीघ्र डाउनलोड वीडियो निर्माता नमूना पाठ वीडियो शीर्षक नमूना पाठ लॉग कापी करें सबटाइटल सबटाइटल डाउनलोड करें सबटाइटल भाषाएँ भाषाएँ, एम्बेड किए सबटाइटल , ऑटो कैप्शन साफ़ करें शॉर्टकट संपादित करें लॉग दिखाएँ शॉर्टकट उन कस्टम शॉर्टकट को संपादित करें जिनका उपयोग कमांड टेम्प्लेट लिखने के लिए किया जा सकता है। चल रहे कार्य प्रायोजक ब्लॉक सेगमेंट को हटाते समय सबटाइटल गलत समय पर हो सकते हैं। अधिकांश वीडियो स्ट्रीमिंग प्लेटफ़ॉर्म ऑडियो और वीडियो को अलग-अलग वितरित करते हैं, आप एक वीडियो-केवल प्रारूप के साथ एक ऑडियो-ओनली प्रारूप का चयन और विलय कर सकते हैं। एसडी कार्ड फ़ोल्डर स्वचालित कैप्शन स्वत: जनरेट किए गए कैप्शन डाउनलोड करें लॉग साफ्ट सबटाइटल एम्बेड करने के लिए, वीडियो को एमकेवी कंटेनर में फिर से जोड़ा जाएगा। आप साफ्ट सबटाइटल के साथ वीडियो देखने के लिए वीएलसी मीडिया प्लेयर या अन्य संगत ऐप्स का उपयोग कर सकते हैं। पसंदीदा ऑडियो प्रारूप असीमित सबसे कम बिटरेट प्रारूप छँटाई yt-dlp के -S विकल्प के साथ स्वरूपों को क्रमबद्ध करना आयात करें शीर्षक नाम बदलें दूसरा मिनट सभी कुकी साफ़ करें ऐप में संगृहीत सभी कुकी हमेशा के लिए हटा दें\? %.2f MB %.2f GB साझा करें स्थिर पूर्व दर्शन अपडेट चैनल ऑटो अपडेट ऑटो अपडेट सक्षम करें खारिज करें लागू करें शुरू अंत नई सुविधाओं और परिवर्तनों का पूर्वावलोकन करने के लिए रिलीज़-पूर्व बिल्ड स्थापित करें। \n \n इन संस्करणों में कुछ अस्थिरता होगी, इसलिए यदि आपको कोई समस्या आती है और भविष्य के लिए ऐप को बेहतर बनाने में हमारी मदद करने के लिए कृपया हमें प्रतिक्रिया देने में संकोच न करें। क्लिप वीडियो ऑडियो गुणवत्ता एकाधिक कुआलिटी मौजूद होने पर ऑडियो बिटरेट सीमित करें अस्थायी फ़ाइलों को आंतरिक निर्देशिका में संग्रहीत करें ऑडियो प्रारूप कोई डाउनलोड मीडिया नहीं प्रयोगात्मक सुविधा सक्षम करें\? प्रारूप चयन पृष्ठ में वीडियो क्लिप बनाएं GitHub बिल्ड पर स्विच करना सील हमेशा सभी के लिए स्वतंत्र और खुला स्रोत होगा। यदि आप इसे पसंद करते हैं, तो कृपया मुझे GitHub पर प्रायोजित करने पर विचार करें! समझ गया प्रायोजक %1$s बिल्ड के लिए ऑटो-अपडेट उपलब्ध नहीं है। यदि आपके डिवाइस पर %1$s स्थापित नहीं है, या सील में आने वाली नई सुविधाओं का पूर्वावलोकन करना चाहते हैं, तो कृपया %2$s पर विचार करें। इस सुविधा का उपयोग करने वाले डाउनलोड वीडियो के चयनित अनुभागों को डाउनलोड करने के लिए FFmpeg को सौंपे जाएंगे, यह सुविधा अभी भी प्रायोगिक है और कटिंग पूरी तरह से सटीक नहीं होगी, सभी प्रारूप इस सुविधा का समर्थन नहीं करते हैं और आपको धीमी डाउनलोड गति का अनुभव हो सकता है। ठीक सुविधा उपलब्ध नहीं है प्रायोजक बीटा कोई कस्टम कमांड कार्य नहीं GitHub पर प्रायोजित करके इस ऐप का समर्थन करें प्रतिक्रिया डेवलपर से संदेश आपका बहुत-बहुत धन्यवाद! सबटाइटल बदलें सबटाइटल को किसी अन्य प्रारूप में बदलें वीडियो विभाजित करें वीडियो को %1$d अध्यायों में विभाजित किया जाएगा उफ़! कुछ गलत हो गया कापी करें और बाहर निकलें URL से वीडियो डाउनलोड करें शुरू करें विस्तृत करें नया डाऊनलोड कार्य \"%1$s\" संपादित करें प्रॉक्सी इंटरनेट कनेक्शन के लिए प्रॉक्सी का उपयोग करें लिगेसी क्वालिटी सूचनाएँ सक्षम करें\? ऐप को डाउनलोड स्थिति और प्रगति के बारे में सूचनाएं पोस्ट करने के लिए आपकी अनुमति की आवश्यकता है। अक्षम करें yt-dlp अपडेट करें निर्देशिका सेट करने के लिए टैप करें कस्टम आदेश निर्देशिका अक्षम फ़ोल्डर पिकर कस्टम आदेशों का उपयोग करते समय आउटपुट निर्देशिका निर्दिष्ट करें अन्य ऐप्स पर साझा करने के लिए MP4 (H.264) स्वरूपों को प्राथमिकता दें संगत ऐप्स में देखने के लिए AV1, VP9 या H.265 स्वरूपों को प्राथमिकता दें डाउनलोड प्रकार कस्टम ऑटो कमांडें फ़ॉर्मेट प्राथमिकता और अधिक जानें अज्ञात नई कुकीज़ उत्पन्न करने के लिए वेबपेज खोलने के वास्ते टैप करें: उपयोगकर्ता-एजेंट हेडर %d आइटम %d आइटम्स कमांड टेम्प्लेट से %1$s को बेहतरी के लिए हटा दें\? फ़ाइल में निर्यात करें yt-dlp वीडियो डाउनलोड करने के लिए एक शक्तिशाली कमांड-लाइन टूल है। सील एक सहज ज्ञान युक्त जीयूआई, सामान्य कमांड के लिए प्रीसेट और अन्य अतिरिक्त सुविधाएं प्रदान करके yt-dlp का उपयोग करना आसान बनाता है। \n \nYt-dlp के उन्नत उपयोग के लिए, सील आपको टर्मिनल की तरह सीधे कस्टम कमांड टेम्पलेट बनाने, सहेजने और निष्पादित करने की अनुमति देता है। \n \nकस्टम कमांड का उपयोग करते समय, अधिकांश GUI विकल्प और सुविधाएँ अक्षम कर दी जाएंगी। डाउनलोड आरकाईव साफ़ करें\? आरकाईव फ़ाइल से %1$s को हमेशा के लिए हटा दें\? प्रीसेट आउटपुट टेम्पलेट आउटपुट फ़ाइल नामों के लिए टेम्पलेट निर्दिष्ट करें डुप्लिकेट डाउनलोड से बचने के लिए डाउनलोड की गई वीडियो आईडी को एक आरकाईव में रिकॉर्ड करें डाउनलोड आरकाईव मेटाडेटा एम्बेड करें आवश्यक है ऑडियो फ़ाइल में मेटाडेटा और वीडियो थंमनेल एम्बेड करें सभी %1$d आइटम दिखाएं सहेजें फार्मेट छँटाई का प्रयोग करें फ़ाइल संपादित करें वेबसाइट अनुकूलता सुनिश्चित करने के लिए फ़ाइल नामों को विशिष्ट वर्णों तक सीमित करें फ़ाइल नाम प्रतिबंधित करें प्लेलिस्ट शीर्षक आपके डाउनलोड इस रूप में सहेजे जाएंगे: सिस्टम सेटिंग्स IPv4 फोर्स करें सभी कनेक्शन IPv4 के माध्यम से बनाएं सबटाइटल की फ़ाइलें रखें अनुमति न दें मोबाइल डाटा से डाउनलोड करने की अनुमति दें? हमेशा की अनुमति एकाधिक ऑडियो स्ट्रीम को मर्ज करें एकाधिक ऑडियो स्ट्रीम को एक फ़ाइल में मर्ज करने की अनुमति दें एक बार की अनुमति दें डाउनलोड में खोजें खोज करें आटो अनुवादित सबटाइटल सभी भाषाओं के लिए स्वतः-अनुवादित सबटाइटल डाउनलोड में उपलब्ध होंगे। ये सबटाइटल ग़लत और समझने में कठिन हो सकते हैं। अगले डाउनलोड के लिए याद रखें पिछले चयन का उपयोग करें कोई नहीं स्वचालित प्रारूप चयन में डाउनलोड करने के लिए सबटाइटल की भाषा, अल्पविराम (काॅमा) से अलग की गई है। रीसेट करें उपशीर्षकों में खोजें जी नहीं, धन्यवाद भविष्य में डाउनलोड के लिए निम्नलिखित भाषाओं को आपकी प्राथमिकता में जोड़ा जाएगा: सबटाइटलों की भाषाएँ अपडेट करें? निर्यात करें डाउनलोड की गई फ़ाइलें आयात नहीं की जाएंगी। आपको उन्हें मैन्युअल रूप से वापस डाउनलोड करना होगा आयात करें पूर्ण बैकअप बैकअप की किसम इस जगह निर्यात करें फाईल क्लिपबोर्ड से आयात करें डाउनलोड इतिहास निर्यात करें? डाउनलोड इतिहास आयात करें? डाउनलोड इतिहास से %1$s निर्यात किया जा रहा है। डाउनलोड की गई फ़ाइलों और प्राथमिकताओं का बैकअप नहीं लिया जाएगा। डाउनलोड इतिहास दोबारा डाउनलोड करें वीडियो डाउनलोड कर लिया गया है. यदि यह अपेक्षित व्यवहार नहीं है, तो कृपया अपने डाउनलोड संग्रह में से जांचें। डाउनलोड इतिहास के लिए %1$s आयात किया गया रीमक्स वीडियो कंटेनर बेहतर अनुकूलता के लिए वीडियो को एमकेवी कंटेनर में रीमक्स करें कुल मिलाकर %2$d वेबसाइटों से %1$d कुकीज़ हर दिन हर हफ्ते हर महीने सभी भाषाएँ प्लेलिस्ट प्रीसेट %1$s को प्राथमिकता दें प्रीसेट संपादित करें सर्वोत्तम उपलब्ध फार्मेट डाउनलोड करें %d वीडियो %d वीडियो %d ऑडियो %d ऑडियो कार्य को कतार में जोड़ा गया जारी रखें प्रारूपों, उपशीर्षकों में से चुनें और आगे अनुकूलित करें अपनी फार्मेट प्राथमिकताओं का उपयोग करके स्वचालित रूप से डाउनलोड करें डाउनलोड कतार डाउनलोड किया गया डाउनलोड बटन पर टैप करें या डाउनलोड शुरू करने के लिए इस ऐप को वीडियो लिंक शेयर करें सभी %1$d लिंक्स में से चयन करें आपको अपने डाउनलोड यहां मिलेंगे नेविगेशन ड्रॉअर दिखाएँ हटाएं फिर से शुरू करें मीडिया जानकारी सामान्य त्रुटियों को ठीक करें और ज्ञात समस्याओं की जाँच करें क्या आपको कोई त्रुटि मिली है? नई समस्या की रिपोर्ट करने से पहले, कृपया हमारे समस्या ट्रैकर को खोजें। कई सामान्य समस्याओं को पहले ही संबोधित किया जा चुका है और उनका दस्तावेज़ीकरण किया जा चुका है। समस्या निवारण समस्या ट्रैकर सहेजे गए लिंक नया लिंक शामिल करें %1$s में शामिल करें ================================================ FILE: app/src/main/res/values-hr/strings.xml ================================================ Postavke Opće, format, prilagođena naredba Preuzmi Poveznica ne može biti prazna Koristite najnoviju inačicu yt-dlp Nije moguće instalirati najnoviju inačicu yt-dlp. Provjerite jeste li povezani s internetskom vezom. Dobavljam informacije o videu… Dopuštenje odbijeno Mapa videozapisa Spremite minijaturu videozapisa kao datoteku Spremi kao zvučni zapis Spremi minijaturu Preuzimanje završeno Preuzmite i spremite zvučni zapis, umjesto videozapisa Datoteku nije bilo moguće preuzeti Preuzimanje „%1$s“ Jezik Postojeće preuzimanje je već pokrenuto Zalijepljen URL iz međuspremnika Kliknite kako biste preuzeli najnoviju inačicu yt-dlp Ukloniti\? Zauvijek ukloniti „%1$s“ iz povijesti preuzimanja? Potvrdi Odustani Preuzimanja Izbriši datoteku O aplikaciji Inačica, povratne informacije, automatsko ažuriranje Natrag Inačica Potražite bilježnike promjena i nove inačice Najnovija inačica Pregledajte GitHub repozitorij i README Video Prilagođena naredba Pokrenite yt-dlp naredbu pomoću prilagođenog predloška Naredbeni predložak Započelo je izvršavanje naredbe Napredno Detaljan izlaz informacija Prikažite detaljne informacije prilikom preuzimanja Prikaz Tamna tema, dinamička boja, jezici Prati sistem Omogućeno Onemogućeno Odustani Konfigurirajte preference prije preuzimanja Izvješće o pogrešci kopirano u međuspremnik Reference korištenja yt-dlp Nepretvoreno Ponovno kodiranje datoteka zvučnih zapisa će prouzročiti gubitak kvalitete zvuka i povećati veličinu datoteke. Najbolja kvaliteta Preferirani format videozapisa Preuzmi Pretvori Zatvori Ne prikazuj više Kliknite „Zalijepi“ kako biste zalijepili poveznicu na videozapis iz međuspremnika. Tada kliknite „Preuzmi“ nakon prilagodbe postavki. Pregledajte postavke preuzimanja i provjerite da li koristite najnoviju inačicu yt-dlp prije nego što počnete koristiti aplikaciju. Preuzimanje Obavijesti me o preuzetim datotekama i preuzimanjima u tijeku Poveznica na videozapis Preuzimanje završeno. Dodirnite kako biste otvorili. Pokretanje prilagođene naredbe… Postavite potrošnju baterije ove aplikacije na „Neograničeno“ u postavkama sustava za preuzimanje u pozadini. Višenitno preuzimanje %d nit(i) bi bilo korišteno kako bi se istovremeno preuzeli DASH/HLS videozapisi. Opcije Dodatne postavke Nije moguće podudaranje URL iz dijeljenog sadržaja Čitanje poveznice na videozapis iz dijeljenog sadržaja… Prikaži više radnji Obavijest o preuzimanju Obavijesti me o preuzetim datotekama i preuzimanjima u tijeku Dohvaćanje informacija o popisu za reprodukciju… Odabir popisa za reprodukciju Odredite koje videozapise želite preuzeti s popisa za reprodukciju „%3$s“ (od %1$d do %2$d). Početak Mapa zvučnih zapisa Odaberite gdje će se spremati datoteke videozapisa i zvučnih zapisa Spremi u poddirektorij Spremite datoteke u mape s nazivima odgovarajućih polja Problem s dozvolom pristupa pohrani Direktoriji izvan Preuzimanja/ i Dokumenti/ nisu podržani Konfiguracija baterije Ignorirajte optimizaciju baterije kako bi aplikacija mogla preuzimati u pozadini Seal preuzima… Nepoznata greška Pomognite prevesti ovu aplikaciju pomoću Hosted Weblate Predložak puta Ugradi titlove u videa, ako su dostupni Novi predložak Oznaka Zauvijek ukloniti „%1$s“ iz naredbenih predložaka? Odabir predložaka GitHub prijava problema Prijavite nam greške u aplikaciji i predložite slijedeću novu značajku Informacije kopirane u međuspremnik Otkazano Ponovno pokreni Greška Kopiraj poveznicu Kopiraj izvještaj Izvezi u međuspremnik Uvezi iz međuspremnika Nedavno Dodano %1$d videozapis(a), %2$d zvučni(h) zapis(a) Odredite koje će SponsorBlock kategorije biti uklonjene ili označene u video datoteci SponsorBlock kategorije Potraži ažuriranja Automatski potraži najnovija ažuriranja na GitHub-u Koristite aria2c kao vanjski program za preuzimanje Za preuzimanja koristite kolačiće formatirane pomoću Netscape-a Čišćenje privremenih datoteka Očistite sve privremene datoteke iz privremenog direktorija %1$d privremena(ih) datoteka izbrisana(o) Privremene datoteke služe kako bi se nastavila otkazana preuzimanja. Jeste li sigurni da ih želite sve izbrisati\? \n \nOvim datotekama možete pristupiti u %1$s Način višestrukog odabira Inkognito Onemogućite povijest preuzimanja Dinamička boja Primijenite boje s pozadina na temu aplikacije Odaberi jezik Općenito Neuspjelo dohvaćanje informacija o videu Zvučni zapis Greška prilikom lijepljenja URL iz međuspremnika Yt-dlp inačica Poveznica kopirana u međuspremnik Otvori poveznicu Ukloni Prilagodite ovo preuzimanje Tamna tema Zasluge Konfiguracija prije preuzimanja Provjereno Zasluge i slobodni softver Uredi Zalijepi Konvertiraj format audio snimke Pretvori u %1$s Minijatura Izlazni put i URL će dodati aplikacija. Format Kvaliteta videozapisa Ograničite kvalitetu videozapisa, kada je dostupno više kvaliteta Neodređeno (zadano) Preferirani format kada ih je više ponuđeno Preuzmi popis za reprodukciju Format videozapisa Korisnički vodič Otvori postavke Pregledajte i upravljajte preuzimanjima, uključujući datoteke videozapisa i zvučnih zapisa. Zadano Preuzmite više videozapisa s popisa za reprodukciju Paralelno preuzmite više dijelova M3U8/MPD videozapisa Kraj Neispravan domet indeksa Preuzimanje popisa za reprodukciju (%1$d/%2$d)… Direktorij preuzimanja Otvori datoteku Prevedi Uredite i upravljajte naredbenim predlošcima Preuzimanje u tijeku… Završeno Ugradi titlove Ukloniti\? Preuzimanje otkazano Preuzimanje Dodano u red čekanja Dobavljanje informacija Rezolucija videozapisa Izvezen(o) %1$d predložak(a) Uvezen(o) %1$d predložak(a) Uklonite ili označite dijelove iz videozapisa pomoću SponsorBlock API Veličina video datoteke %1$d zadataka preuzimanja Zauvijek ukloniti %1$d predmet(a) iz povijesti preuzimanja\? Ažuriranje na najnoviju inačicu nije uspjelo Ažuriraj Trenutna inačica je ažurna Neispravan unos Nema prikaza minijature tijekom preuzimanja Privatnost Koristi prilagođenu naredbu Onemogući pregled Privatan direktorij Pohranite preuzimanja u skrivenom direktoriju Prikaži bilježnik Bilježnik Preuzmi koristeći mobilne podatke Mreža Najniža kvaliteta Ograničenje brzine, program za preuzimanje, kolačići Izreži ilustraciju Odaberite videozapise koje želite preuzeti s popisa za reprodukciju „%1$s“ Video (bez zvuka) Generiraj nove kolačiće Ukloniti kolačiće za „%1$s“? Spremljeni kolačići za ovu stranicu se neće izbrisati. Neke opcije neće biti dostupne prilikom korištenja prilagođenih naredbi Odaberite format preuzimanja prije preuzimanja Kako to funkcionira\? Mapa SD kartice Automatski titlovi Brzo preuzimanje Preuzmi automatski generirane titlove Primjer teksta videostvarača Uredi prečace Očisti Dodaj Odabrano: %1$d Predloženo Preuzimanje s nekih internetskih stranica zahtijeva informacije o potvrdi korisničkog računa. Kliknite „Generiraj nove kolačiće“, unesite URL stranice te se prijavite sa svojim korisničkim računom. Aplikacija će ih generirati za vas. Telegram kanal Primjer teksta naslova videozapisa Titlovi Preuzmi titlove Jezici, ugrađeni titlovi, automatski titlovi Kopiraj bilježnik Da bi se titlovi ugradili, videozapisi će se konvertirati u mkv kontejner. Možete koristiti VLC Media Player ili druge kompatibilne aplikacije za gledanje videozapisa s titlovima. Titlovi mogu biti pogrešno tempirani nakon uklanjanja SponsorBlock segmenta. Maksimalna brzina Tamna tema visokog kontrasta Ograničite maksimalnu brzinu preuzimanja Odabir formata Koristi kolačiće Kolačići Preuzimanje putem mobilnih podataka je onemogućeno u postavkama Ova datoteka više nije dostupna Datotečni format, kvaliteta videa, titlovi Inačica Yt-dlp-a, obavijest, popis za reprodukciju Nedostupno Izrežite ugrađenu sliku u kvadratni format Ograničenje brzine Većina platforma strujanja videozapisa dostavljaju odvojen zvučni zapis i videozapis. Možete odabrati i spojiti format koji sadrži samo zvučni zapis s formatom koji sadrži samo videozapis u jedan videozapis. Matrix prostor Odaberi sve Jezici titlova Prečaci Uredite prilagođene prečace koji se mogu koristiti kao sastavljači predložaka naredbi. Radnje u tijeku Dopustite preuzimanje medija putem ograničenog interneta Kvaliteta zvučnog zapisa Nema preuzetih medija Beta %.2f MB %.2f GB Odbaci Primjeni Sortiranje formata pomoću yt-dlp opcije -S Uvezi Naslov Izreži videozapis Najniži bitrate Ograničite bitrate zvučnog zapisa, kada je dostupno više kvaliteta Sortiranje formata Preimenuj Pohranite privremene datoteke u unutarnji direktorij sekunda Sponzoriraj minuta Očisti sve kolačiće Preuzimanja izvršena ovom značajkom bit će izaslani FFmpeg-u kako bi preuzeo odabrane dijelove videozapisa. Ova značajka je još u razvoju te je moguće da izrezivanje ne bude posve točno uz moguću sporiju brzinu preuzimanja. Napravite isječke videozapisa na stranici za odabir formata prebacivanje na GitHub inačice Značajka nije dostupna Nema prilagođenih naredbenih radnji Preuzmite videozapise s URL-a Pretvori titlove Pretvorite titlove u jedan drugi format Razdvoji videozapis Videozapis će biti podijeljen u %1$d poglavlja Ups! Nešto nije u redu Kopiraj i izađi Dijeli Beta Stabilan Instalirajte beta inačice aplikacije kako biste pregledali nove značajke i promjene. \n \nMoguća je nestabilnost ovih inačica, molimo ne ustručavajte se dati nam povratne informacije u slučaju problema s aplikacijom. One će nam pomoći poboljšati buduće inačice aplikacije. Automatsko ažuriranje Omogući automatsko ažuriranje Neograničeno Preferirani format zvuka Podržite razvoj ove aplikacije njenim sponzoriranjem na GitHub-u Seal će uvijek biti besplatan i otvorenog koda za sve. Ako vam se sviđa, razmislite o tome da me sponzorirate na GitHub-u! Audio format Omogućiti eksperimentalne značajke\? Automatsko ažuriranje nije dostupno za %1$s inačice. Ako nemate %1$s instaliran na svom uređaju ili želite pregledati nadolazeće nove značajke u Seal-u, razmislite o %2$s. U redu Razumijem Obavijest od razvojnog programera Hvala vam puno! Proširi Novi zadatak preuzimanja Pokreni Uredi „%1$s“ Početak Kraj Kanal ažuriranja Zauvijek izbrisati sve kolačiće pohranjene u aplikaciji\? Sponzori Povratne informacije Ažuriraj yt-dlp Onemogući Dodirnite za postavljanje direktorija Direktorij prilagođenih naredbi Onemogućeno Birač mapa Navedite izlazni direktorij kada koristite prilagođene naredbe Postavke formata Koristite proksi za internetske veze Zastario Kvaliteta Omogućiti obavijesti\? Aplikacija treba vaše dopuštenje za objavljivanje obavijesti o statusu i napretku preuzimanja. Proksi Vrsta preuzimanja Prilagođen Automatski Naredbe Saznaj više Nepoznato Preferiranje MP4(H.264) formata za dijeljenje s drugim aplikacijama Preferiranje AV1, VP9 ili H.265 formata za gledanje u kompatibilnim aplikacijama Dodirnite za otvaranje web stranice za generiranje novih kolačića: Zauvijek ukloniti %1$s iz naredbenih predložaka\? %d predmet %d predmeta %d predmeta Izvezi u datoteku Zaglavlje User-Agent Yt-dlp je moćan alat naredbene linije za preuzimanje videozapisa. Seal olakšava korištenje yt-dlp-a pružajući intuitivni GUI, unaprijed postavljene postavke za uobičajene naredbe i druge dodatne značajke. \n \nZa napredno korištenje yt-dlp-a, Seal vam omogućuje stvaranje, spremanje i izravno izvršavanje prilagođenih naredbenih predložaka, baš kao u terminalu. \n \nKada koristite prilagođene naredbe, većina GUI opcija i značajki bila bi onemogućena. Unaprijed postavljeno Izlazni predložak Navedite predložak za nazive izlaznih datoteka Očistiti arhivu preuzimanja\? Zauvijek ukloniti %1$s iz arhivske datoteke\? Snimite ID-ove preuzetih videozapisa u arhivu kako biste izbjegli dvostruka preuzimanja Arhiva preuzimanja Ugradi metapodatke Ugradite metapodatke i minijaturu videozapisa u audio datoteku Obavezno Prikaži sve predmete: %1$d Spremi Koristi sortiranje formata Uredite datoteku Ograničite nazive datoteka na određene znakove kako biste osigurali kompatibilnost Ograniči nazive datoteka Web stranica Naslov popisa za reprodukciju Vaša preuzimanja bit će spremljena kao: Čuvaj datoteke titlova Postavke sustava Prisili IPv4 Uspostavi sve veze putem IPv4 Dozvoli jednom Nemoj dozvoliti Dozvoli uvijek Dozvoli sjedinjavanje audio strimova u jednu datoteku Dozvoliti preuzimanje putem mobilnih podataka? Sjedini audio strimove Automatski prevedeni titlovi Automatski prevedeni titlovi za sve jezike će biti dostupni za preuzimanje. Ovi titlovi mogu biti netočni i teško razumljivi. Izgled i osjećaj Koristi prethodni odabir Ništa Zapamti za sljedeće preuzimanje Jezik titlova za preuzimanje u odabiru automatskog formata, odvojen zarezima. Resetiraj Traži u titlovima Ne hvala Sljedeći jezici će se dodati tvojim postavkama za buduća preuzimanja: Aktualizirati jezike titlova? Traži Traži u preuzimanjima Međuspremnik Uvezi iz Izvoz %1$s iz povijesti preuzimanja. Preuzete datoteke i postavke se neće sigurnosno kopirati. Ukupno %1$d kolačića od %2$d web stranica Sučelje i interakcija Izvezi Uvezi Potpuna sigurnosna kopija Vrsta sigurnosne kopije Izvezi u Datoteka Izvesti povijest preuzimanja? Uvesti povijest preuzimanja? Preuzete datoteke se neće uvesti. Morat ćete ih ponovo preuzeti ručno Povijest preuzimanja Svaki dan Svaki tjedan Svaki mjesec %1$s uvezeno u povijest preuzimanja Preuzmi ponovo Video je preuzet. Ako ovo nije očekivano ponašanje, provjeri svoju arhivu preuzimanja. Svi jezici Popis za reprodukciju Nastavi Postavka Preferiraj %1$s Biraj iz formata, titlova i dodatno ih prilagodi Uredi postavku %d video %d videa %d videa Zadatak dodan u red Kontejner konvertiranog videa Konvertiraj videa u MKV kontejner za bolju kompatibilnost %d audio snimka %d audio snimke %d audio snimki Preuzmi automatski koristeći tvoje postavke formata Preuzmi najbolji dostupni format ================================================ FILE: app/src/main/res/values-hu/strings.xml ================================================ Video mappa Ikon mentése Beállítások A link nem lehet üres Mentés hangfájlként Általános, formátum, egyéni parancs Video ikon elmentése fájlként Nem sikerült telepíteni a legújabb yt-dlp-t. Kérlek bizonyosodj meg arról, hogy csatlakozva vagy az internethez. Videó információinak lekérése… Nem sikerült lekérni a videó adatait Megjelenítési nyelv Állítsa be a felhasználói nyelvet Egy letöltési feladat már fut URL másolása vágólapról Kattints ide, hogy telepítsd a legújabb yt-dlp-t Eltávolítás\? Eltávolítod véglegesen a \"%1$s\" a letöltési listádból\? A vágólapon lévő URL nem egyezik Hang A link vágólapra másolva Link megnyitása Névjegy Verzió, visszajelzés, automatikus frissítés Legújabb kiadása Keresd a változásnaplókat és az új verziókat Keresd a GitHubon és olvasd a README-t Videó Ellenőrizve Stáb Stáb és libre szoftver Egyedi parancs Parancssablon Parancs futtatása Kijelző Sötét téma, dinamikus színek, nyelvek Rendszer Be Részletezett kimenet Ki Letöltés előtti konfiguráció Ezen letöltés beállítása Ikon Beillesztés Yt-dlp használati hivatkozások A kimeneti útvonalat és az URL-t az alkalmazás adja hozzá. Hang formátum konvertálása Mégsem Formátum Az audió fájlok újrakódolása minőségbeli veszteséget és a fájlméret növekedését okozza. Előnyben részesített videó formátum Preferált formátum, ha több van megadva Videó formátum Konvertál Letöltés Beállítások megnyitása Kattintson a \"Beillesztés\" gombra, hogy a vágólapról beszúrd a videó linkjét. Ezután kattints a \"Letöltés\" gombra a beállítások módosítása után. Vess egy pillantást a letöltési beállításokra, és győződjön meg arról, hogy az yt-dlp legfrissebb verziójával rendelkezel, mielőtt használnád. Alapértelmezett Letöltés befejeződött. Kattints a megnyitáshoz. Egyéni parancsok futtatása… Többszálas letöltés Töltsd le párhuzamosan több részét a M3U8/MPD videóknak Beállítások További beállítások Az URL nem található a megosztott tartalomban Értesítés a letöltött fájlokról és azok folyamatáról Indítás Vége Érvénytelen indextartomány Letöltés mappa Hangfájlok mappa A letöltések/ és a dokumentumok/ könyvtáron kívüli könyvtárak nem támogatottak Ismeretlen hiba Fordítás Segits fordítani ezt az appot a Hosted Weblate-n Elérési út sablon Letöltés Letöltés \"%1$s\" Letöltés kész Hozzáférés megtagadva A legújabb yt-dlp használva Általános Eltávolítás Yt-dlp verzió Letöltések Megerősít Szerkesztés Mégsem Fájl törlése Videó link Vissza Verzió Futtassa a yt-dlp parancsot egyéni sablonnal Haladó Sötét téma A letöltés előtt konfigurálja a beállításokat Videó minősége A hibajelentés a vágólapra másolva Nem konvertált Konvertálja ebbe %1$s Korlátozza a videók minőségét, ha több is jelen van Nincs megadva (alapértelmezett) Kezeld az alkalmazáson belüli letöltéseket, beleértve a videókat és hangfájlokat. Legjobb minőség Bezár Ne jelenjen meg újra Felhasználói útmutató Lejátszásilista letöltése Több videó letöltése egy lejátszási-listáról Letöltés Letöltés hangfájlként, videó helyett Részletes üzenetek megjelenítése letöltés közben Kérlek állítsd át az akkumulátor korlátozásokat ezen az applikáción \"korlátlanra\" a rendszerbeállításokban. %d szálat/kat használnának fel a DASH/HLS natív videó egyidejű letöltésére. Lejátszási lista információinak lekérése… Lejátszási lista kiválasztása Adja meg a „%3$s” lejátszási listáról letöltendő videók tartományát (%1$d és %2$d között). Fájlok mentése az annak megfelelően elnevezett mappákba Letöltési értesítés Lejátszási lista letöltése (%1$d/%2$d)… Válassza ki a videók és hangfájlok tárolási helyét Mentés alkönyvtárba Adattár engedéllyel kapcsolatos probléma Videó link olvasása megosztott tartalomból… További műveletek megjelenítése A háttérben történő letöltéshez kapcsold ki az alkalmazás akkumulátor-optimalizálását Seal letöltése folyamatban van… A fájlt nem sikerült letölteni Értesítés a letöltött fájlokról és annak a folyamatról Akkumulátor beállítások Fájl megnyitása Újraindítás Sorba állva Befejezve Meghiúsult Hiba Link másolása Hibajelentés másolása Videó felbontás Videó fájl mérete Letöltés Információk lekérése Feliratok beágyazása Feliratok beágyazása a videókba, ha elérhetőek Új sablon Címke Eltávolítja\? Végleg eltávolítja a „%1$s” elemet a parancssablonokból\? Sablon kiválasztása Parancssablonok szerkesztése és kezelése Letöltés folyamatban… Letöltési feladat megszakítva GitHub probléma Hibajelentés vagy új funkció kérése Adatok a vágólapra másolva Exportálás a vágólapra Importálás vágólapról %1$d sablon lett exportálva %1$d sablon lett importálva %1$d letöltési művelet Nem sikerült frissíteni a legújabb verzióra El akar távolítani %1$d tételt a letöltési előzmények közül\? Szekciók eltávolítása vagy jelölése videókban a SponsorBlock API használatával A vidóból eltávolítandó Sponsorblock szekciók megadása vagy eltávolítása SponsorBlock kategóriák Frissítések automatikus keresése GitHubon A jelenlegi verzió a legfrissebb Frissítés Aria2c használata külső letöltőként Privát Letöltési előzmények letiltása Dinamikus szín Az app témája követi a háttérképe színét Letöltés mobil internettel Média letöltésének engedélyezése, akkor is, ha fizetős hálózaton van A mobil internetes letöltés le van tiltva a beállításai szerint Hálózat Mértékhatár Maximum letöltési sebesség korlátozása Maximum mérték Magas kontrasztú sötét téma Nem megfelelő bemenet Nem elérhető Fájl formátum, videó minőség, feliratok Yt-dlp verzió, értesítés, playlist Mértékhatár, letöltő, sütik Előnézet letiltása Magánélet Válassza ki a videókat, amiket le akar tölteni a \"%1$s\" playlistről Minden kiválasztása %1$d kiválasztva Új sütik generálása Legalacsonyabb minőség Videó (hang nélkül) Ajánlott Formátum kiválasztása Album borító vágása Beágyazott képet négyzetre vagása Egyéni parancs használata Privát mappa Tárolja a letöltéseket egy rejtett mappában Legutóbb hozzáadva %1$d videó, %2$d audió fájl Frissítések keresése Átmeneti fájlok törlése Minden átmeneti fájl törlése az ideiglenes mappából %1$d átmeneti fájl törölve Netscape formátumú sütik használata a letöltéshez Többválasztós mód Ez a fájl már nem elérhető Az átmeneti fájlok felhasználhatóak megszakított letöltéseket folytatására. Biztosan törölni szeretné őket? \n \nEzek a fájlok elérhetőek itt: %1$s Előnézet letiltása a letöltéseknél A letöltés megkezdése előtt válassza ki a letöltendő formátumot Hangminőség perc Összes süti törlése %.2f GB yt-dlp frissítése Sütik log mutatása Felirat Feliratok letöltése %.2f MB Előnézet Kezdés SD kártya mappa Felirat nyelvei log másolása Stabil Automatikus frissítés Csatorna frissítése Automatikus frissítés bekapcsolása Vég Korlátlan Legalacsonyabb bitráta Cím Átnevezés második Rendben Értem Feliratok konvertálása Másolás és kilépés Gyorsgombok szerkesztése Hozzáadás Gyorsgombok Folyamatban lévő feladatok Megosztás Visszajelzés Nincs letöltött média Béta Kiterjesztés Kezdés Elvetés Telegram csatorna Log Hogyan működik? Gyors letöltés Szponzor Szponzorok Hangformátum Funkció nem elérhető Minőség Sütik használata Néhány opció nem elérhatő testreszabott parancsok mellett A yt-dlp egy hatékony parancssori eszköz videók letöltéséhez. A Seal megkönnyíti a yt-dlp használatát úgy, hogy egy intuitív GUI-t, előbeállításokat gyakran használt parancsokhoz és sok mást kínál. \n \nA yt-dlp haladó használatához a Seal lehetővé teszi egyedi parancssablonok létrehozását, elmentését és lefuttatását, akárcsak egy terminálban. \n \nEgyéni parancsok használatakor a legtöbb grafikus felület opció és szolgáltatás le van tiltva. Automatikus feliratok Automatikusan generált feliratok letöltése Videó címének minta szövege Nyelvek, feliratok beágyazása, automatikus feliratozás Eltávolítja ezt a bejegyzést a következőhöz: \"%1$s\"? Vegye figyelembe, hogy az ezen az oldalon tárolt cookie-k nem törlődnek. Mátrix helye A legtöbb oldal külön tárolja a hangot, és a videót. Ki tud választani csak hang, és csak videó formátumú tartalmakat, hogy aztán egybeolvassza őket. Clear Bizonyos oldalakról való letöltéshez hitelesítés szükséges. Kattintson a(z) \"Új sütik generálása\" gombra, írja be az oldal címét, majd jelentkezzen be az oldalra. Ezután az alkalmazás legenerálja magától a szükséges adatokat. Videó készitő példa szöveg ================================================ FILE: app/src/main/res/values-in/strings.xml ================================================ Mengambil informasi video… Izin ditolak Tidak dapat mengunduh berkas Mengunduh \"%1$s\" Tidak dapat mengambil informasi video Atur bahasa tampilan Tugas pengunduhan yang ada sudah berjalan Tempel URL Tidak dapat mencocokkan URL di papan klip Versi yt-dlp Singkirkan? Konfirmasi Batalkan Unduhan Audio Buka tautan Singkirkan Versi, masukan, pembaruan otomatis Kembali Versi Versi terbaru Periksa repositori GitHub dan README Kredit dan perangkat lunak bebas Templat perintah Mulai jalankan perintah Lanjutan Keluaran terperinci Cetak pesan terperinci saat mengunduh Nyala Sesuaikan unduhan ini Laporan kesalahan disalin ke papan klip Gambar mini Tempel Jalur keluaran dan URL akan ditambahkan oleh aplikasi. Ubah format audio Tidak diubah Meng-enkode ulang berkas audio akan menyebabkan kehilangan kualitas audio dan penambahan ukuran berkas. Kualitas video Kualitas terbaik Format pilihan ketika beberapa disediakan Format video Ubah Unduh Panduan pengguna Buka pengaturan Periksa dan kelola unduhan dalam aplikasi, termasuk video dan berkas audio. Unduh daftar putar Unduh beberapa video dari daftar putar Bawaan Unduhan Tautan video Unduhan selesai. Ketuk untuk membuka. Menjalankan perintah khusus… Harap atur penggunaan baterai aplikasi ini ke \"Tidak dibatasi\" di pengaturan sistem untuk mengunduh di latar belakang. Unduh lebih banyak bagian video dari M3U8/MPD secara paralel %d bagian akan digunakan untuk mengunduh video asli DASH/HLS secara bersamaan. Pilihan Beritahu berkas yang diunduh dan kemajuannya Tampilkan lebih banyak tindakan Beritahu berkas yang diunduh dan kemajuannya Pilihan daftar putar Mulai Rentang indeks tidak sah Mengunduh daftar putar (%1$d/%2$d)… Pilih tempat untuk menyimpan berkas video dan audio Simpan ke subdirektori Simpan berkas dalam folder yang diberi nama sesuai bidang masing-masing Masalah izin penyimpanan Pengaturan baterai Folder video Menggunakan versi yt-dlp terbaru Tidak dapat memasang yt-dlp versi terbaru. Pastikan Anda terhubung ke internet. Unduhan selesai Umum Bahasa tampilan Perintah khusus Tekan untuk memasang yt-dlp versi terbaru Mati Tentang Tema gelap, warna dinamis, bahasa Referensi penggunaan yt-dlp Singkirkan \"%1$s\" dari riwayat unduhan Anda untuk selamanya? Hapus berkas Video Sudah diperiksa Kredit Jalankan perintah yt-dlp menggunakan templat khusus Tampilan Lihat log perubahan dan versi baru Sunting Atur sebelum unduh Format Tema gelap Sistem Batalkan Atur preferensi sebelum mengunduh Format video pilihan Tutup Ubah ke %1$s Batasi kualitas video ketika ada beberapa yang tersedia Tidak ditentukan (bawaan) Jangan tampilkan lagi Tekan \"Tempel\" untuk mendapatkan link video dari papan klip Anda. Kemudian tekan \"Unduh\" setelah menyesuaikan pengaturannya. Lihat pengaturan unduhan dan pastikan Anda memiliki yt-dlp versi terbaru sebelum menggunakannya. Unduhan multi-utas Pengaturan tambahan Tidak dapat mencocokkan URL dari konten yang dibagikan Membaca tautan video dari konten yang dibagikan… Pemberitahuan unduhan Mengambil informasi daftar putar… Akhir Abaikan pengoptimalan baterai pada aplikasi ini untuk mengunduh di latar belakang Terjemahkan Tentukan rentang video yang akan diunduh dari daftar putar \"%3$s\"(dari %1$d hingga %2$d). Simpan sebagai audio Simpan thumbnail Pengaturan Umum, format, perintah khusus Unduhan Tautan tidak boleh kosong Unduh dan simpan audio, daripada video Simpan thumbnail video sebagai berkas Tautan disalin ke papan klip Folder audio Direktori unduhan Direktori di luar Unduhan/ dan Dokumen/ tidak didukung Seal sedang mengunduh… Kesalahan tidak diketahui Bantu terjemahkan aplikasi ini pada Hosted Weblate Awalan Sematkan terjemahan Sematkan soft-sub ke dalam video jika tersedia Templat baru Label Pilihan templat Singkirkan? Singkirkan \"%1$s\" dari templat perintah untuk selamanya? Edit dan kelola templat perintah Sedang dalam proses pengunduhan… Tugas unduhan dibatalkan Masalah GitHub Kirimkan masalah untuk pelaporan bug atau permintaan fitur Info disalin ke papan klip Mengambil info Buka berkas Ulangi Dalam antrean Selesai Mengunduh Dibatalkan Salin laporan Kesalahan Salin tautan Ukuran berkas video Resolusi video Ekspor ke papan klip Impor dari papan klip %1$d Tugas unduhan %1$d Template yang telah diimpor %1$d Template yang telah diekspor Periksa pembaruan Periksa otomatis untuk versi terbaru di GitHub Versi saat ini adalah yang terbaru Gagal memperbarui ke versi terbaru Pembaruan Gunakan aria2c sebagai pengunduh eksternal Gunakan cookie berformat Netscape untuk pengunduhan Bersihkan berkas sementara %1$d berkas sementara telah dihapus Hapus semua berkas sementara dari direktori sementara Berkas sementara dapat digunakan untuk melanjutkan unduhan yang dibatalkan. Apakah Anda yakin akan menghapus semua berkas sementara? \n \nAnda dapat mengakses berkas ini di %1$s Baru Ditambahkan %1$d video, %2$d berkas audio Singkirkan %1$d item dari riwayat unduhan Anda untuk selamanya? Singkirkan atau tandai bagian dalam video dengan API SponsorBlock Kategori SponsorBlock Tentukan kategori SponsorBlock yang akan disingkirkan atau ditandai dalam berkas video Mode pilih banyak Batasan nilai Jaringan Kecepatan maksimum Samaran Mematikan riwayat unduhan Warna dinamis Terapkan warna dari wallpaper ke tema aplikasi Unduh menggunakan seluler Perbolehkan mengunduh media saat terhubung ke jaringan terukur Mengunduh dengan jaringan seluler dimatikan berdasarkan pengaturan Anda Berkas ini tidak lagi tersedia Batasi kecepatan unduh maksimum Tema gelap kontras tinggi Masukan tidak sah Kualitas paling rendah Tidak tersedia Versi yt-dlp, pemberitahuan, daftar putar Batasan nilai, pengunduh, kuki Matikan pratinjau Gunakan perintah khusus Direktori pribadi Potong gambar Potong gambar tersemat menjadi kotak Format berkas, kualitas video, terjemahan Privasi Tidak ada tampilan thumbnail selama pengunduhan Menyimpan unduhan di dalam direktori tersembunyi Pilih semua Pilih video untuk diunduh dari daftar putar \"%1$s\" %1$d dipilih Disarankan Pemilihan format Video (tanpa audio) Pilih format untuk diunduh sebelum memulai unduhan Hasilkan kuki baru Gunakan Kuki Beberapa pilihan tidak tersedia ketika menggunakan perintah khusus Bagaimana cara kerjanya\? Singkirkan entri untuk \"%1$s\"? Perhatikan bahwa kuki yang disimpan untuk situs ini tidak akan dihapus. Mengunduh dari beberapa situs memerlukan informasi autentikasi akun. Klik \"Hasilkan kuki baru\", masukkan URL situs web dan kemudian masuk dengan akun Anda di halaman browser, aplikasi akan membuatnya untuk Anda. Saluran Telegram Space Matrix Kuki Sebagian besar platform streaming video mengirimkan audio dan video secara terpisah, Anda dapat memilih dan menggabungkan format audio saja dengan format video saja menjadi satu video. Unduh keterangan yang dibuat secara otomatis Contoh teks judul video Contoh teks pembuat video Terjemahan Bahasa, terjemahan tersemat, keterangan otomatis Salin catatan Hapus Tugas yang berjalan Tampilkan catatan Catatan Keterangan otomatis Sunting pintasan khusus yang dapat digunakan untuk membuat templat perintah. Unduh terjemahan Folder kartu SD Unduhan cepat Bahasa terjemahan Sunting pintasan Tambahkan Pintasan Terjemahan mungkin tidak pas waktunya ketika menyingkirkan bagian SponsorBlock. Untuk menyematkan terjemahan, video akan di-mux ulang ke kontainer MKV. Anda dapat menggunakan Pemutar Media VLC atau aplikasi lainnya yang kompatibel untuk menonton video dengan terjemahan yang disemat. %.2f GB %.2f MB Saluran pembaruan Bagikan Pembaruan otomatis Stabil Pratinjau Pasang versi prarilis untuk melihat pratinjau fitur dan perubahan baru. \n \nAkan ada beberapa ketidakstabilan dalam versi ini, jadi jangan ragu untuk memberi kami masukan jika Anda mengalami masalah apa pun untuk membantu kami meningkatkan aplikasi di masa mendatang. Nyalakan pembaruan otomatis Buang Klip video Akhir Awal Terapkan Kecepatan bit terendah Format audio yang disukai Tidak terbatas Kualitas audio Urutan format Mengurutkan format dengan pilihan -S dari yt-dlp Judul Ubah nama detik Hapus semua kuki tersimpan dalam aplikasi selamanya\? Batasi kecepatan bit audio ketika beberapa kualitas tersedia Impor Hapus semua kuki menit Simpan berkas sementara di direktori internal Sponsor Dukung aplikasi ini dengan mensponsori di GitHub Seal akan selalu gratis dan sumbernya terbuka untuk semua orang. Jika Anda menyukainya, silakan sponsori saya di GitHub! Masukan Sponsor Tidak ada media yang terunduh Beta Nyalakan fitur percobaan? Format audio Buat klip video dalam halaman pemilihan format Pengunduhan menggunakan fitur ini akan didelegasikan ke FFmpeg untuk mengunduh bagian video yang dipilih, fitur ini masih tahap percobaan dan pemotongan tidak akan sepenuhnya akurat, tidak semua format mendukung fitur ini dan Anda mungkin mengalami kecepatan pengunduhan yang lebih lambat. Mengerti Fitur tidak tersedia Pembaruan otomatis tidak tersedia untuk versi %1$s. Jika Anda belum memasang %1$s di perangkat Anda, atau ingin melihat pratinjau fitur-fitur baru yang akan datang di Seal, silahkan pertimbangkan %2$s. beralih ke versi GitHub Oke Tidak ada tugas perintah khusus Pesan dari pengembang Terima kasih banyak! Unduh video dari URL Ubah terjemahan Pisahkan video Aduh! Ada yang salah Salin dan keluar Video akan dipisahkan menjadi %1$d bagian Ubah terjemahan ke format lainnya Perluas Mulai Sunting \"%1$s\" Tugas unduhan baru Perbarui yt-dlp Proksi Gunakan proksi untuk sambungan internet Lawas Matikan Pemilih folder Tentukan direktori keluaran ketika menggunakan perintah khusus Kualitas Nyalakan pemberitahuan? Aplikasi memerlukan izin Anda untuk mengirim pemberitahuan tentang status dan proses pengunduhan. Ketuk untuk menyiapkan direktori Direktori perintah khusus Dimatikan Lebih suka format MP4(H.264) untuk berbagi ke aplikasi lain Lebih suka format AV1, VP9, atau H.265 untuk ditonton dalam aplikasi yang kompatibel Jenis unduhan Khusus Otomatis Pelajari lebih lanjut Tidak diketahui Perintah Preferensi format Ketuk untuk membuka laman web untuk menghasilkan kuki baru: Singkirkan %1$s dari templat perintah selamanya? %d item Tajuk User-Agent Ekspor ke berkas yt-dlp adalah alat baris perintah yang hebat untuk mengunduh video. Seal membuatnya lebih mudah untuk memakai yt-dlp dengan menyediakan GUI yang intuitif, prasetel untuk perintah umum, dan fitur tambahan lainnya. \n \nUntuk pengguna yt-dlp tingkat lanjut, Seal memungkinkan Anda untuk membuat, menyimpan, dan menjalankan templat perintah khusus secara langsung, seperti dalam terminal. \n \nKetika menggunakan perintah khusus, kebanyakan opsi dan fitur GUI akan dinonaktifkan. Prasetel Templat keluaran Tentukan templat untuk nama berkas keluaran Hapus arsip unduhan\? Singkirkan %1$s dari berkas arsip selamanya? Rekam ID video yang telah diunduh dalam arsip untuk menghindari unduhan ganda Unduh arsip Simpan Sematkan metadata Gunakan penyortiran format Batasi nama berkas pada karakter tertentu untuk memastikan kompatibilitas Diperlukan Tampilkan semua %1$d item Batasi nama berkas Sunting berkas Sematkan metadata dan thumbnail video ke dalam berkas audio Unduhan Anda akan disimpan sebagai: Simpan berkas terjemahan Situs web Judul daftar putar Pengaturan sistem Paksa IPv4 Buat semua sambungan melalui IPv4 Ijinkan sekali Selalu ijinkan Jangan ijinkan Ijinkan mengunduh dengan data seluler? Gabungkan beberapa aliran audio Izinkan beberapa aliran audio untuk digabungkan menjadi satu berkas Cari di unduhan Cari Terjemahan otomatis Terjemahan otomatis untuk semua bahasa akan tersedia dalam unduhan. Terjemahan ini mungkin tidak akurat dan sulit dipahami. Ingat untuk unduhan berikutnya Gunakan pilihan sebelumnya Tidak ada Bahasa terjemahan yang akan diunduh dalam pilihan format otomatis, dipisahkan dengan koma. Atur ulang Cari dalam terjemahan Tidak, terima kasih Perbarui bahasa terjemahan? Bahasa berikut akan ditambahkan ke preferensi Anda untuk unduhan di masa mendatang: Ekspor Impor Cadangan penuh Jenis pencadangan Ekspor ke Berkas Papan klip Impor dari Ekspor riwayat unduhan? Impor riwayat unduhan? Mengekspor %1$s dari riwayat pengunduhan. Berkas yang terunduh dan preferensi tidak akan dicadangkan. Berkas yang terunduh tidak akan diimpor. Anda harus mengunduhnya lagi secara manual Riwayat pengunduhan %1$s telah diimpor ke riwayat unduhan Unduh ulang Video sudah diunduh. Jika ini bukan perilaku yang diharapkan, silakan periksa arsip unduhan Anda. Wadah video mux ulang Mux ulang video ke dalam wadah MKV untuk kompatibilitas yang lebih baik %1$d kuki dari %2$d situs web keseluruhan Setiap minggu Setiap bulan Setiap hari Daftar putar Sunting prasetel %d video Tugas ditambahkan ke antrean Diunduh Semua bahasa Prasetel Lebih suka %1$s Unduh format terbaik yang tersedia Anda dapat menemukan unduhan Anda di sini Ketuk tombol unduh atau bagikan tautan video ke aplikasi ini untuk memulai pengunduhan Semua %d suara Pilih dari %1$d tautan Pilih dari format, terjemahan, dan sesuaikan lebih lanjut Unduh secara otomatis menggunakan preferensi format Anda Lanjutkan Antrean unduhan Tampilkan laci navigasi Lanjutkan Hapus Info media Pemecahan masalah Mengalami masalah? Sebelum melaporkan isu baru, silakan cari pelacak isu kami dahulu. Banyak masalah umum telah diselesaikan dan didokumentasikan di sana. Pelacak isu Perbaiki kesalahan umum dan periksa isu yang diketahui Tautan tersimpan Tambahkan tautan baru Tambahkan ke %1$s ================================================ FILE: app/src/main/res/values-it/strings.xml ================================================ Sei sicuro/a di rimuovere «%1$s» dalla tua cronologia di scaricamento\? Il link non può essere vuoto Salva come audio Salva la miniatura Download Scarica e salva l\'audio invece del video Salva la miniatura del video come un file Non è possible installare l\'ultima versione di yt-dlp. Per favore, verifica di essere connesso ad Internet. Recupero delle informazioni del video… Permesso negato Scaricamento completato Scarica «%1$s» Impossibile recuperare le informazioni del video Uno scaricamento è già in esecuzione Incolla URL Impossibile confrontare l\'URL negli appunti Versione yt-dlp Versione, commenti, aggiornamento automatico Indietro Versione Scopri i changelog e le nuove versioni Ultima versione Controlla la repository GitHub e il README Video Controllato Crediti Esegui il comando yt-dlp con un template personalizzato Avanzate Output dettagliato Tema scuro, colore dinamico, lingue Tema scuro Annulla Configura prima dello scaricamento Configura le preferenze prima dello scaricamento Configura questo scaricamento Report dell\'errore copiato negli appunti Incolla Referenze di uso di yt-dlp Il percorso di output e l\'URL verrano aggiunti dall\'applicazione. Non convertito Formato La ri-codifica dei file audio comporta una perdita di qualità audio e un aumento delle dimensioni del file. Qualità migliore Formato video preferito Cartella video Impostazioni Generale, formato, comando personalizzato Stai usando l\'ultima versione di yt-dlp Impossibile scaricare il file Informazioni Generale Lingua Imposta lingua Rimuovere\? Clicca per installare l\'ultima versione di yt-dlp Conferma Annulla Audio Scaricamenti Collegamento copiato negli appunti Apri il collegamento Rimuovi Elimina file Crediti e software liberi Comando personalizzato Template di comando Modifica Inizia l\'esecuzione del comando Stampa messaggi dettagliati durante lo scaricamento Visualizzazione Sistema Disattivato Attivato Converti il formato audio Converti in %1$s Qualità video Non specificato (predefinito) Limita la qualità del video quando sono presenti più video Miniatura Formato predefinito quando ne sono forniti molteplici Formato video Clicca «Incolla» per ottenere il collegamento del video dai tuoi appunti. Poi clicca «Scarica» dopo aver aggiustato le sue impostazioni. Scarica playlist Scarica più video da una playlist Predefinito Si consiglia di impostare l\'utilizzo della batteria su «Senza limitazioni» nelle impostazioni di sistema per scaricare in sfondo. Scarica più parti di video M3U8/MPD in parallelo Avviso di scaricamento Selezione della playlist Inizio Fine Range dell\'indice invalido Scaricando playlist (%1$d/%2$d)… Notifica il processo di scaricamento e i file scaricati Non mostrare più Converti Scarica Guida utente Chiudi Apri le impostazioni Controlla e organizza gli scaricamenti nell\'app, inclusi i file video e audio. Opzioni Scarica Controlla le impostazioni di scaricamento e assicurati di avere l\'ultima versione di yt-dlp prima di utilizzarlo. Specifica l\'intervallo di video da scaricare dalla playlist «%3$s» (da %1$d a %2$d). Link del video Esecuzione del comando personalizzato… Scaricamento terminato. Clicca per aprire. Impostazioni aggiuntive Recupero delle informazioni della playlist… Mostra più azioni Notifica il processo di scaricamento e di file scaricati Directory degli scaricamenti Seleziona dove conservare i file video e audio Cartella audio Lettura del collegamento video da un contenuto condiviso… Impossibile abbinare l\'URL al contenuto condiviso Salva in una sottodirectory Configurazione della batteria Seal sta scaricando… Salva i file in cartelle chiamate come i rispettivi siti web Problema relativo all\'autorizzazione di archiviazione Contribuisci a tradurre questa applicazione su Hosted Weblate Errore sconosciuto Ignora l\'ottimizzazione della batteria per lo scaricamento dell\'app in sfondo Traduci Le directory al di fuori di Download/ e Documenti/ non sono supportate Download multi-thread I thread %d sarebbero utilizzati per scaricare video nativi DASH/HLS in parallelo. Modello di percorso Incorpora i sottotitoli Incorpora i sottotitoli nel video se disponibili Nuovo modello Etichetta Rimuovere\? Rimuovere «%1$s» dai modelli dei comandi\? Selezione del modello Modifica e gestisci i modelli di comando Download in corso… Scaricamento annullato In coda Finito Scaricamento Annullato Recupero delle informazioni Apri il file Riavvia Informazioni copiate negli appunti Invia una segnalazione di bug o una richiesta per nuove funzioni Segnala un problema su GitHub Copia il collegamento Errore Copia il rapporto Risoluzione video Dimensione del file video Esporta negli appunti %1$d file video, %2$d file audio Importa dagli appunti Modello(i) %1$d esportato(i) Modello(i) %1$d importato(i) Aggiunto di recente Sei sicuro/sicura di rimuovere %1$d elemento(i) dalla tua cronologia di scaricamento\? %1$d file temporanei eliminati Usa aria2c come downloader esterno Controlla gli aggiornamenti Verifica automatica la versione più recente su GitHub I file temporanei possono essere utilizzati per riprendere i download annullati. Sei sicuro/a di eliminare tutti questi file? \n \nPuoi accedere a questi file in %1$s La versione attuale è aggiornata Cancella i file temporanei Elimina tutti i file temporanei dalla directory temporanea Impossibile eseguire l\'aggiornamento alla versione più recente Aggiorna Categorie SponsorBlock %1$d attività di scaricamento Rimuovi o segna i segmenti dei video con l\'API SponsorBlock Specifica le categorie SponsorBlock da rimuovere o segnare nel file video Utilizzare i cookie formattati Netscape per i download Incognito Disattiva la cronologia degli scaricamenti Modalità multiselezione Colore dinamico Applica i colori degli sfondi al tema dell\'applicazione Scarica con dati mobili Consenti lo scaricamento di contenuti multimediali quando si è connessi alle reti a pagamento Lo scaricamento con la rete mobile è disabilitato in base alle impostazioni Il file non è più disponibile Rete Limite di velocità Limita la velocità massima degli scaricamenti Velocità massima Tema scuro ad alto contrasto Inserimento non valido Qualità più bassa Non disponibile Formato file, qualità video, sottotitoli Versione Yt-dlp, notifica, playlist Disattiva l\'anteprima Ritaglia immagine Ritaglia l\'immagine incorporata in un quadrato Limite di velocità, scaricatore, cookie Nessuna visualizzazione delle miniature durante il download Privacy Usa un comando personalizzato Directory privata Memorizza gli scaricamenti in una directory nascosta Seleziona tutto %1$d selezionato/i Seleziona i video da scaricare dalla playlist «%1$s» Video (senza audio) Seleziona il formato da scaricare prima di avviare lo scaricamento Suggerito Selezione del formato Genera nuovi cookie Spazio Matrix Cookie Usa i cookie Rimuovere i cookie per «%1$s»? Nota che i cookie salvati per questo sito non saranno rimossi. Alcune opzioni non sono disponibili quando si utilizza un comando personalizzato Come funziona\? Canale Telegram La scaricamento da alcuni siti richiede le informazioni di autenticazione dell\'account. Clicca su „Genera nuovi cookie“, inserisci l\'URL del sito web e poi accedi con il tuo account nella pagina del browser: l\'applicazione lo genererà per te. La maggior parte delle piattaforme di streaming video fornisce audio e video separatamente; è possibile selezionare e unire un formato solo audio con un formato solo video in un unico video. Aggiungi Mostra registro Registro Scaricamento rapido Testo di esempio del creatore di video Sottotitolo Lingue dei sottotitoli Copia il registro Modifica le scorciatoie Scorciatoie Attività in esecuzione Cartella della scheda SD Scarica i sottotitoli autogenerati Scarica i sottotitoli Lingue, sottotitoli incorporati, sottotitoli automatici Cancella Modifica le scorciatoie personalizzate che possono essere utilizzate per comporre i modelli di comando. Sottotitoli automatici Titolo del video testo di esempio I sottotitoli possono risultare non sincronizzati quando si rimuovono i segmenti SponsorBlock. Per incorporare i sottotitoli, i video verranno rimuxati in un contenitore mkv. Puoi usare VLC Media Player o altri programmi compatibili per guardare i video con i sottotitoli incorporati. %.2f MB %.2f GB Bitrate minore Assortimento formato Formato audio Nessun media scaricato Beta Attivare la funzione sperimentale\? I download che utilizzano questa funzione saranno delegati a FFmpeg per scaricare sezioni selezionate del video; questa funzione è ancora sperimentale e il taglio non sarà completamente accurato, non tutti i formati supportano questa funzione e si potrebbero verificare velocità di scaricamento più basse. Canale aggiornamento Aggiornamento automatico Abilita l\'aggiornamento automatico Formato audio preferito Rinomina Eliminare tutti i cookie salvati nell\'app\? Installa build pre-rilascio per vedere in anteprima nuove funzioni e cambiamenti. \n \nPossono esserci delle instabilità in queste versioni, quindi perfavore non esitare a darci un commento se trovi qualche problema per aiutarci a migliorare l\'applicazione in futuro. Inizio Condividi Stabile Anteprima Fine Illimitato Limita bitrate audio quando multiple qualità sono presenti Qualità audio Assortimento formati con la opzione -S di yt-dlp Titolo minuto Cancella tutti i cookie secondo Sponsor Commenti Sponsor Crea videoclip nella pagina di selezione del formato Scarta Applica Video clip Sostieni questa applicazione sponsorizzando su GitHub Seal sarà sempre libero e gratuito per tutti. Se ti piace, considera la possibilità di sponsorizzarmi su GitHub! Importa Salva file temporanei nella directory interna Messaggio dello sviluppatore Grazie mille! L\'aggiornamento automatico non è disponibile per le build %1$s. Se non hai installato %1$s sul dispositivo, o se vorresti provare in anteprima le nuove funzionalità di Seal, considera %2$s. Va bene Ho capito Funzione non disponibile passando alle build di GitHub Nessuna attività di comando personalizzata Convertisci i sottotitoli in un altro formato Dividi il video Converti i sottotitoli Scarica i video dall\'URL Ops! Qualcosa è andato storto Copia ed esci Il video sarà diviso in capitoli %1$d Espandi Nuova attività di scaricamento Inizia Modifica «%1$s» Aggiorna yt-dlp Proxy Qualità yt-dlp è uno strumento da riga di comando per scaricare i video. Seal permette di usare facilmente yt-dlp provvedendo un\'intuitiva interfaccia grafica e altre funzionalità aggiuntive. \n \nPer un utilizzo avanzato di yt-dlp, Seal permette la creazione, l\'esecuzione e il salvataggio di comandi personalizzati, come fosse un terminale. \n \nDurante l\'utilizzo di comandi personalizzati la maggior parte delle opzioni dell\'interfaccia grafica verranno disabilitate. L\'app ha bisogno del tuo permesso per inviare le notifiche sullo stato e progresso del download. Preferisci il formato MP4(H.264) per la condivisione con altre app Comandi Sconosciuto Impara di più Tipo di download Cartella dei comandi personalizzati Specifica la cartella di output quando si utilizzano comandi personalizzati Clicca per impostare una cartella Formati preferiti Disabilita Disabilita Automatico Usare il proxy per le connessioni internet Clicca per aprire una pagina web per generare nuovi cookie: Abilitare le notifiche\? Rimuovere %1$s dai modelli di comando definitivamente? Sperimentale Archivio dei download Registra gli ID video scaricati in un archivio per evitare download duplicati Modifica file Salva Rimuovere definitivamente %1$s dal file di archivio? Incorpora metadati e miniatura video nel file audio Utilizza l\'ordinamento dei formati Forza ipv4 Effettuare tutte le connessioni tramite IPv4 Incorpora metadati Consenti solo una volta Consenti sempre Non consentire Consentire il download con dati mobili? Unisci più fonti audio Consenti l\'unione di più fonti audio in un unico file I tuoi download verranno salvati come: Intestazione agente utente Conserva i file dei sottotitoli Necessario Mostra tutti i %1$d elementi Esporta su file Personalizzato Limita i nomi dei file Limita i nomi dei file a caratteri specifici per garantire la compatibilità Sito Titolo della playlist Selettore di cartelle Preferisci i formati AV1, VP9 o H.265 per la visione nelle app compatibili Modello di output Predefinite Specificare il modello per i nomi dei file di output Impostazioni di sistema %d oggetto %d oggetti %d oggetti Cancellare l\'archivio dei download? Il video è stato scaricato con successo. Se questo è stato un comportamento inaspettato, controllare l\'archivio dei download. Tipo di backup Esportare in Sottotitoli tradotti automaticamente I sottotitoli automaticamente tradotti saranno disponibili nei download. I sottotitoli possono essere inaccurati e difficile da capire. Ricorda per i prossimi download Lingua dei sottotitoli da scaricare nella sezione formato automatico, separati da virgole. Aspetto Interfaccia ed interrogazioni Usa selezione precedente Nulla Reset Cerca nei sottotitoli No grazie La lingua seguente verrà aggiunta alle preferenze per i download futuri: Esportare Importare Backup completo File Appunti Importare da Esportare la cronologia sei download? Importare la cronologia dei download? I file scaricati non saranno importati. Dovrai scaricarli di nuovo manualmente Esportazione in corso di %1$s dalla cronologia dei download. I file scaricati e le preferenze non saranno esportate nel backup. Cronologia dei download Aggiornare le lingue dei sottotitoli? %1$s importato dai download con successo Riscarica Cerca nei file scaricati Cerca Ogni giorno Ogni settimana Ogni mese Tutte le lingue Continua %1$d cookie da %2$d siti web in totale %d video %d video %d video %d audio %d audio %d audio Playlist Preferire %1$s Preimpostazione Scegli tra i formati, i sottotitoli e le ulteriori personalizzazioni Modifica preset Scarica automaticamente usando le tue preferenze di formattazione Scarica nel formato migliore Attività aggiunta alla coda Contenitore video Remux Remux dei video in un contenitore MKV per una migliore compatibilità Scaricato Troverai qui i tuoi download Tutto Seleziona da %1$d link Tocca il pulsante di download o condividi un link video a questa applicazione per avviare il download Scarica coda Mostra il cassetto di navigazione Continuare Cancellare Info media Risoluzione dei problemi Tracker dei problemi Correggi gli errori comuni e verifica i problemi noti Hai riscontrato un errore? Prima di segnalare un nuovo problema, cerca nel nostro issue tracker. Molti problemi comuni sono già stati affrontati e documentati lì. ================================================ FILE: app/src/main/res/values-iw/strings.xml ================================================ שמור את התמונה הממוזערת של הסרטון כקובץ תיקיית סרטונים שמור כשמע הגדרות כללי, פורמט, פקודה מותאמת אישית הורדה הקישור לא יכול להיות ריק הורד ושמור שמע במקום סרטון שמור תמונה ממוזערת משתמש בגרסה העדכנית ביותר של yt-dlp משיג מידע על הסרטון… אחורה גרסה מחיקת קובץ אודות חפש יומני שינויים וגרסאות חדשות גרסה, משוב, עדכון אוטומטי וידאו המהדורה האחרונה בדוק את מאגר GitHub ואת ה-README אודיו ביטול כללי הסרה ההורדה הושלמה להסיר\? להסיר לצמיתות את \"%1$s\" מהיסטוריית ההורדות? אישור הורדות שגיאה בהתקנת הגרסה החדשה ביותר של yt-dlp, אנא ודא שהמכשיר מחובר לאינטרנט. הגישה נדחתה לא ניתן להוריד את הקובץ הזה מוריד את \"%1$s\" לא ניתן להשיג את מידע הסרטון שפת תצוגה הגדר שפת תצוגה משימת הורדה כבר פועלת הדבק כתובת URL לא נמצאה כתובת URL בלוח גרסת yt-dlp לחץ כדי להתקין את גרסת yt-dlp העדכנית ביותר הקישור הועתק ללוח פתיחת קישור מסומן תודות תודות ותוכנות תומכות פקודה מותאמת אישית פורמט אודיו מועדף ייבוא שניה דקה מתקדם פלט מפורט ברירת מחדל הורדה קישור לסרטון אפשרויות לא הורדה מדיה להפעיל תכונה ניסיונית? %.2f MB %.2f GB הורדה בתהליך… ערוץ עדכון ערכת נושא כהה, ערכת נושא דינאמית, שפות ההורדה הסתיימה. הקש לפתיחה. מפעיל פקודות מותאמות אישית… הורד במקביל חלקים נוספים של סרטוני וידאו M3U8/MPD הגדרות נוספות קידומת להסיר לצמיתות את \"%1$s\" מתבניות הפקודה? המידע הועתק ללוח תבנית חדשה הגרסה המותקנת היא העדכנית ביותר הקובץ הזה אינו זמין יותר %1$d משימות הורדה איכות הפעל התראות? השבתה האיכות הנמוכה ביותר משימות פועלות קיצורים שפות, הטמעת כתוביות, כתוביות אוטומטיות העתק לוג תצוגה מקדימה יציב הפעלת עדכון אוטומטי השלכה חסות משוב המרת כתוביות תודה רבה לך! פיצול וידאו המר את הכתוביות לפורמט אחר אופס! משהו השתבש העתקה ויציאה שגיאה ייצוא ללוח ייבוא מהלוח גודל קובץ וידאו יוצאו %1$d תבניות נוסף לאחרונה בדוק אוטומטית את הגרסה העדכנית ביותר ב-GitHub בחר סרטונים להורדה מהפלייליסט \"%1$s\" בחר הכל %1$d נבחרו חתוך את התמונה המוטמעת לריבוע בחר את הפורמט להורדה לפני תחילת ההורדה הורדה מהירה כותרת סרטון לדוגמה הורדת כתוביות שפת כתוביות נותני חסות שינוי שם פורמט אודיו מעבר ל-GitHub builds הבנתי אישור התכונה לא זמינה הורד סרטונים מכתובת האתר הרחבה משימת הורדה חדשה התחלה עריכת \"%1$s\" סיום מוריד בוטל העתק קישור העתק דו\"ח רזולוציית וידאו יובאו %1$d תבניות נמחקו %1$d קבצים זמניים ניתן להשתמש בקבצים זמניים כדי לחדש הורדות שבוטלו. האם אתה בטוח שברצונך למחוק את כל הקבצים האלה? \n \nאתה יכול לגשת לקבצים האלה ב-%1$s השבת היסטוריית הורדות אפשר הורדה גם כאשר אתה מחובר לרשת נמדדת ערכת נושא כהה עם ניגודיות גבוהה מצב בחירה מרובה מצב אנונימי מוצע ערוץ טלגרם כתוביות אוטומטיות הצג יומן לוג ערוך את הקיצורים המותאמים אישית שניתן להשתמש בהם כדי ליצור תבניות פקודות. Seal יהיה תמיד קוד פתוח ובחינם לכולם. אם אתה אוהב את זה, בבקשה שקול לתת לי חסות ב-GitHub! האם למחוק לצמיתות את כל העוגיות המאוחסנות באפליקציה? צור קטעי וידאו בדף בחירת הפורמט פרוקסי השתמש בפרוקסי כדי להתחבר לאינטרנט מיושן היישום צריך את ההרשאה שלך כדי להציג התראות לגבי סטטוס הורדה והתקדמות. הורדות המשתמשות בתכונה זו יואצלו ל-FFmpeg להורדת קטעים נבחרים של הסרטון, תכונה זו עדיין ניסיונית והחיתוך לא יהיה מדויק לחלוטין. לא כל הפורמטים תומכים בתכונה זו וייתכן שתחווה מהירויות הורדה איטיות יותר. עריכה תבנית פקודה פורמט איכות וידאו סיום אינדקס לא חוקי (מחוץ לטווח) תיקיית אודיו בחר היכן לאחסן קובצי וידאו/אודיו אישור הסרה השתמש בעוגיות דו\"ח שגיאה הועתק ללוח פתח הגדרות המרה ל-%1$s הגבל את מהירות ההורדה צור עוגיות חדשות חתוך תמונת אלבום חפש עדכונים השתמש בפקודה מותאמת אישית המרה הורדה באמצעות רשת סלולרית מושבתת בהגדרות שלך הדבק הורדת פלייליסט התחלה תצורת סוללה בחירת תבנית איך זה עובד? מערכת עדכון השבת תצוגה מקדימה פרטיות תצוגה ערכת נושא כהה מופעל כבוי ביטול הגדרה לפני הורדה התאם הורדה זו הגדר העדפות לפני הורדה תמונה ממוזערת ללא המרה הגבל את איכות הווידאו כאשר קיימות מספר אופציות לא צוין (ברירת מחדל) פורמט וידאו מועדף פורמט וידאו הורדה סגור אל תציג שוב מדריך למשתמש לחץ \"הדבק\" כדי לקבל קישור לוידאו מהלוח שלך. ואז לחץ \"הורדה\" אחרי התאמת ההגדרות האלה. בדוק ונהל הורדות מתוך האפליקציה, כולל סרטונים וקובצי אודיו. הטמע כתוביות בסרטון אם זמינות הורדת מספר סרטונים מתוך פלייליסט הצג התראה על התקדמות והשלמת הורדות לא נמצאה כתובת URL בתוכן ששותף קורא קישור לסרטון מתוכן משותף… הצג פעולות נוספות התראה על הורדה הצג התראה על התקדמות והשלמת הורדות בחירת פלייליסט שמור לתת תיקייה בעיית הרשאות אחסון התעלם מאופטימיזציה של סוללה כדי לאפשר לאפליקציה להוריד ברקע תרגום כתוביות מוטמעות ערוך ונהל תבניות פקודות בעיית GitHub צור בעיה כדי לדווח באג או לבקש תכונה ממתין בתור הושלם משיג מידע פתח קובץ מתחיל מחדש עוגיות צבע דינמי קצב מקסימלי לא זמין גרסת yt-dlp, התראות, פלייליסט תיקייה פרטית תיקיית כרטיס ה-SD ניקוי ערוץ קיצורים הוספה מוריד פלייליסט (%1$d/%2$d)… רשת הגבלת קצב הגבלת קצב, הורדה, עוגיות לא להציג תמונה ממוזערת במהלך ההורדה וידאו (ללא אודיו) הורד כתוביות שנוצרו באופן אוטומטי יוצר וידאו לדוגמה שיתוף לא מוגבלת איכות אודיו כותרת ניקוי כל העוגיות בטא הסרטון יפוצל ל-%1$d פרקים הורדה מאתרים מסוימים דורשת אימות חשבון. לחץ על \"צור עוגיות חדשות\", הזן את כתובת האתר ולאחר מכן התחבר עם חשבונך בדף הדפדפן, והאפליקציה תייצר אותן עבורך. yt-dlp הוא כלי שורת פקודה רב עוצמה להורדת סרטונים. Seal מקל על השימוש ב-yt-dlp על ידי מתן GUI אינטואיטיבי, הגדרות קבועות מראש לפקודות נפוצות ותכונות נוספות אחרות. \n \nלשימוש מתקדם ב-yt-dlp, אפליקציית Seal מאפשרת ליצור, לשמור ולבצע תבניות פקודות מותאמות אישית ישירות, ממש כמו בטרמינל. \n \nבעת שימוש בפקודות מותאמות אישית, רוב אפשרויות ה-GUI והתכונות יהיו מושבתות. רוב פלטפורמות הזרמת הווידאו מספקות אודיו ווידאו בנפרד, ניתן לבחור ולמזג פורמט אודיו בלבד עם פורמט וידאו בלבד לסרטון אחד. התקן גירסה מוקדמת לתצוגה מקדימה של תכונות ושינויים חדשים. \n \nתיתכן חוסר יציבות בגרסאות אלה, אז אנא אל תהסס לשלוח לנו משוב אם אתה נתקל בבעיות כלשהן, כדי לעזור לנו לשפר את האפליקציה בעתיד. עדכון אוטומטי תמכו באפליקציה זו על ידי מתן חסות ב-GitHub כתוביות חיתוך וידאו התחלה הגבל את קצב סיביות השמע כאשר קיימות איכויות מרובות הודעה מהמפתח עדכון yt-dlp הדפס הודעות מפורטות בזמן הורדה מושבת בוחר תיקיה קידוד מחדש של קובצי אודיו יגרום לאובדן איכות שמע ולהגדלת גודל הקובץ. משימת ההורדה בוטלה נקה קבצים זמניים אנא הגדר את השימוש בסוללה של אפליקציה זו ל\"בלתי מוגבל\" בהגדרות המערכת כדי להוריד ברקע. מחק את כל הקבצים הזמניים מהתיקייה הזמנית משיג מידע פלייליסט… שגיאה לא ידועה עזור לתרגם אפליקציה זו ב- Hosted Weblate תווית קלט לא חוקי אחסן את ההורדות בתיקייה נסתרת בחירת פורמט המר פורמט שמע האיכות הכי טובה הורדה באמצעות ת\'רדים מרובים %d ת\'רדים ישמשו להורדה מקבילה של וידאו DASH/HLS. אפשר פעם אחת אפשר תמיד אל תאפשר להסיר לצמיתות %1$d פריטים מהיסטוריית ההורדות? קטגוריות SponsorBlock בחר קטגוריות SponsorBlock להסרה או סימון בקובץ הווידאו העדכון לגרסה האחרונה נכשל להסיר את הערך עבור \"%1$s\"? שים לב שהעוגיות המאוחסנות עבור אתר זה לא יימחקו. שמור קבצי כתוביות השתמש ב-aria2c בתור מוריד חיצוני התחל הרצת פקודה ציין את טווח הסרטונים להורדה מהפלייליסט \"%3$s\" (מ-%1$d עד %2$d). Seal מוריד… הורדה באמצעות חיבור סלולרי ייצוא לקובץ הסר או סמן קטעים בסרטונים באמצעות SponsorBlock API כתוביות מתורגמות אוטומטית הגדרות מערכת מראה ותחושה השתמש בבחירה הקודמת חיפוש בכתוביות ממשק ואינטראקציות לא תודה ייבוא מ כותרת פלייליסט כפה IPv4 גיבוי מלא סוג גיבוי ייצוא אל היסטוריית הורדות חיפוש בהורדות לאפשר הורדה באמצעות חיבור סלולרי? מיזוג זרמי אודיו מרובים ההורדות שלך יישמרו בתור: הצג את כל %1$d הפריטים פריט אחד שני פריטים %d פריטים זכור להורדה הבאה בצע את כל החיבורים באמצעות IPv4 שמירה החלה מותאם אישית אוטומטי פקודות לא ידוע נדרש אתר אינטרנט ללא איפוס ייבוא ייצוא לוח קובץ לייבא היסטוריית הורדות? לייצא היסטוריית הורדות? הורדה מחדש חיפוש מיון פורמטים עם אפשרות -S של yt-dlp הקש כדי לפתוח דף אינטרנט ליצירת קובצי עוגיות חדשים: ספריית פקודות מותאמות אישית העדף פורמט AV1, VP9 או H.265 לצפייה באפליקציות תואמות %1$d סרטונים, %2$d קובצי שמע כדי להטמיע כתוביות רכות, סרטונים יבוצעו מחדש לתוך מיכל mkv. אתה יכול להשתמש ב-VLC Media Player או באפליקציות תואמות אחרות כדי לצפות בסרטונים עם כתוביות רכות. עדכון אוטומטי אינו זמין עבור בניית %1$s. אם אין לך %1$s מותקן במכשיר שלך, או אם תרצה לקבל תצוגה מקדימה של תכונות חדשות של Seal, שקול להשתמש ב-%2$s. כותרת User-Agent החל צבעים מהטפט על ערכת הנושא של האפליקציה חלק מהאפשרויות אינן זמינות בעת שימוש בפקודה מותאמת אישית כתוביות עשויות להופיע באופן שגוי לאחר הסרת קטעי חסות באמצעות SponsorBlock. אחסן קבצים זמניים בספרייה הפנימית הקש כדי להגדיר תיקייה בחר את ספריית הפלט בעת שימוש בפקודות מותאמות אישית העדף פורמטים של MP4(H.264) לשיתוף עם אפליקציות אחרות סוג הורדה העדפות פורמט למד עוד הרץ פקודת yt-dlp עם תבנית מותאמת אישית עיין בהגדרות ההורדה וודא שיש לך את הגרסה העדכנית ביותר של yt-dlp לפני השימוש. תיקיות מחוץ ל\"הורדות\" ו\"מסמכים\" אינן נתמכות השתמש בעוגיות בפורמט Netscape להורדות תבנית פלט צ\'אט Matrix פורמט מועדף כאשר מרובים זמינים אין משימות פקודה מותאמת אישית להסיר לצמיתות את %1$s מתבניות הפקודה? ערוך קובץ הטמע מטא נתונים הטמע מטא נתונים ותמונה ממוזערת של הסרטון בקובץ האודיו השתמש במיון פורמטים אפשר למזג זרמי אודיו מרובים לקובץ בודד מיון פורמטים תיקיית הורדה הוראות לשימוש ב-yt-dlp נתיב פלט וכתובת URL יתווספו על ידי האפליקציה. שמור קבצים בתיקיות ששמן על פי השדות המתאימים (לדוגמה שם ערוץ) פורמט קובץ, איכות וידאו, כתוביות מייצא את %1$s מהיסטוריית ההורדות. קבצים שהורדו והעדפות לא יגובו. הקבצים שהורדו לא ייובאו. תצטרך להוריד אותם מחדש באופן ידני הגבל שמות קבצים הגבל שמות קבצים לתווים ספציפיים כדי להבטיח תאימות קצב סיביות הנמוך ביותר הגדרות קבועות מראש בחר את התבנית עבור שמות קובצי הפלט שפת הכתוביות להורדה בבחירת פורמט \"אוטומטי\", מופרדת בפסיקים. השפות הבאות יתווספו להעדפה שלך להורדות עתידיות: לעדכן את שפות הכתוביות? לנקות את ארכיון ההורדות? להסיר לצמיתות את %1$s מקובץ הארכיון? %1$s יובא להיסטוריית ההורדות ארכיון הורדות שמור מזהי וידאו שהורדו בארכיון כדי למנוע הורדות כפולות כתוביות מתורגמות אוטומטית לכל השפות יהיו זמינות בהורדות. כתוביות אלו עשויות להיות לא מדויקות וקשות להבנה. הסרטון הורד. אם התנהגות זו אינה צפויה, אנא בדוק את ארכיון ההורדות. כל השפות המשך הגדרה מראש העדף %1$s בחירה מפורמטים, כתוביות, והתאמות נוספות הורדה אוטומטית עם שימוש בהעדפות הפורמט ערוך הגדרה מראש הורדת הפורמט הכי טוב שזמין %d סרטון %d סרטונים %d סרטונים משימה הוספה לתור שנה סוג קובץ הסרטון שנה סוג קובץ סרטונים לסוג-MKV בישביל תאימות טובה יותר פלייליסטים (רשימת שירים להשמעה) %d אודיו %d אודיוים %d אודיוים %1$d קבצי cookies מתוך %2$d אתרים בסך הכול כל יום כל שבוע כל חודש תוכל למצוא את ההורדות שלך כאן לחץ על כפתור ההורדה או שתף קישור לסרטון על האפליקציה כדי להתחיל הורדה הורד הכל בחר מתוך %1$d קישורים תור ההורדות פתרון בעיות מעקב אחר בעיות תקן שגיאות נפוצות וחפש בעיות ידועות נתקלת בשגיאה? לפני דיווח על בעיה חדשה, אנא חפש במעקב הבעיות שלנו. בעיות נפוצות רבות כבר טופלו ותועדו שם. הצג מגירת ניווט מחק להמשיך מידע על המדיה ================================================ FILE: app/src/main/res/values-ja/strings.xml ================================================ 一般、形式、カスタムコマンド ダウンロード リンクを空にはできません 動画ではなく音声をダウンロードして保存します 動画のサムネイルをファイルとして保存する 最新版の yt-dlp を使用中です 最新版の yt-dlp をインストールできませんでした。インターネットに接続されているかご確認ください。 動画の情報を取得中… 権限が拒否されました ダウンロード完了 ファイルをダウンロードできませんでした 「%1$s」をダウンロード 動画の情報を取得できませんでした 全般 表示言語 表示言語を設定 既存のダウンロードタスクが既に実行されています URL を貼り付け クリップボード内に URL がありません yt-dlp バージョン クリックして最新の yt-dlp をインストール 除去しますか? ファイルも削除 このアプリについて カスタムコマンド コマンドテンプレート 編集 コマンドの実行を開始 高度な設定 詳細な出力 ダウンロード時に詳細なメッセージを表示します サムネイル 出力パスと URL はアプリによって追加されます。 音声形式の変換 画質 最高品質 画質の選択肢が複数のとき画質を制限する 指定なし (標準) 優先する動画形式 形式の選択肢が複数のとき優先する形式 動画形式 ダウンロード 設定後、「ダウンロード」をクリックしてください。 利用前にダウンロードの設定と、yt-dlpが最新版であることをご確認ください。 再生リストからダウンロード 再生リストから複数の動画をダウンロードします デフォルト ダウンロード ダウンロードしたファイルや進捗状況を通知する カスタムコマンド実行中… マルチスレッドダウンロード M3U8/MPD動画の各部分を並行してダウンロード オプション 音声として保存 動画フォルダ サムネイルを保存 設定 キャンセル ダウンロード一覧 リンクをクリップボードにコピーしました リンクを開く 戻る GitHubリポジトリとREADMEを確認する ダウンロード履歴から「%1$s」を除去しますか? 確認 音声 除去 yt-dlp 使用法の解説 バージョン、フィードバック、自動更新 バージョン 最新リリース 更新履歴と新しいバージョンを確認する %1$s に変換 動画 確認済み クレジット オフ キャンセル 表示 ダウンロード前の設定 ダウンロード前に設定を調整する クレジットと自由なライセンスのソフト カスタムテンプレートを使用して yt-dlp のコマンドを実行 変換なし ダークテーマ、ダイナミックカラー、言語 システムに従う ダークテーマ オン ダウンロードの設定 貼り付け 変換 設定を開く エラーレポートをクリップボードにコピーしました 形式 音声ファイルを再エンコードすると、音質が低下しファイルサイズは増加します。 閉じる 再度表示しない ユーザーガイド クリップボードから動画リンクを取得するには「貼り付け」をクリックします。 動画のアドレス 動画や音声ファイルなど、本アプリでのダウンロード一覧を管理します。 バックグラウンドでダウンロードを行うため、システムの設定で本アプリのバッテリー使用量を「制限なし」に設定してください。 ダウンロード完了。タップで再生します。 同時に%d個のスレッドを用いて、DASH/HLS ネイティブ動画をダウンロードします。 追加の設定 共有コンテンツからのURLと一致しません ほかの操作を表示 ダウンロードの通知 再生リストの情報を取得中… 再生リストの選択範囲 再生リスト「%3$s」からダウンロードする動画の範囲 (%1$dから%2$d) を指定します。 開始 通し番号の範囲が正しくありません 音声フォルダ ダウンロードフォルダ ダウンロードしたファイルと進捗状況の通知 共有されたコンテンツから動画リンクを読み込み中… 終了 再生リストからダウンロード中… (%1$d/%2$d) 動画と音声ファイルの保存先を選択 保存領域への権限の問題 Download/とDocuments/配下のディレクトリ以外には対応しません バッテリー設定 未知のエラー Hosted Weblateから本アプリの翻訳にご協力ください パスのテンプレート 字幕を埋め込む 字幕ファイルが利用できれば動画に埋め込む 新しいテンプレート 名前 テンプレート選択 コマンドテンプレートを編集・管理する ダウンロードタスクがキャンセルされました GitHubの問題報告場所 バグ報告や機能要望 情報がクリップボードにコピーされました 削除しますか? Seal でダウンロード中… サブフォルダに保存 以下の項目名の各フォルダ内にファイルを保存 バックグラウンドでダウンロードするにはこのアプリのバッテリー最適化を無効にしてください 翻訳 「%1$s」をコマンドテンプレートから削除しますか? ダウンロード中… エラー 情報をコピー 完了 ダウンロード中 キャンセルしました 情報の取得中 キューに追加しました ファイルを開く 再開 リンクをコピー SponsorBlockで動画のシーンを削除または識別 SponsorBlockのカテゴリ クリップボードからインポート クリップボードにエクスポート %1$dつのテンプレートを出力しました %1$dつのテンプレートを読み込みました 動画の解像度 動画ファイルのサイズ %1$d 件のダウンロードタスク 最近追加された項目 %1$d の動画ファイル、%2$d の音声ファイル %1$d 項目をダウンロード履歴から除去しますか? 動画ファイルから削除または識別する SponsorBlock のカテゴリを指定します 更新を確認 利用不可 カスタムコマンドを使用する 複数選択モード 従量制ネットワークに接続中でもダウンロードする モバイルデータ通信でのダウンロード プライバシー 非公開フォルダ 一時ファイルはキャンセルしたダウンロードを再開するために利用できます。本当にすべて削除しますか? \n \n以下からもアクセスできます: %1$s 設定によって、モバイルデータ通信でのダウンロードは無効になっています ネットワーク 速度制限、ダウンローダー、クッキー 一時ファイルを削除する このファイルはありません 速度制限 最大ダウンロード速度を制限する 最大速度 高コントラストのダークテーマ 無効な入力値です ファイル形式、画質、字幕 yt-dlpのバージョン、通知、再生リスト ダウンロード履歴を無効にする GitHubに最新版があるか自動的に確認する 最新版です 一時ディレクトリ内の一時ファイルをすべて削除 %1$d個の一時ファイルを削除しました シークレットモード ダイナミックカラー 壁紙の色を本アプリのテーマに反映する プレビューを無効にする 最新版への更新に失敗しました 更新 外部ダウンローダーとしてaria2cを使用する クッキー ダウンロードにネットスケープ形式のクッキーを利用する 最低品質 ダウンロード中にサムネイルを表示しない ファイル形式の選択 ダウンロード開始前にファイル形式を選択する 隠しフォルダに保存する 提案 新しいクッキーを生成 すべて選択 再生リスト「%1$s」からダウンロードする動画を選択 %1$d項目を選択 クッキーを使用する 「%1$s」用のこの項目を削除しますか?このサイト向けに保存したクッキーは削除されません。 動画 (音声なし) アートワークを切り抜く 埋め込んだ画像を正方形に切り抜く ログ 一部のサイトからのダウンロードには、アカウントの認証情報が必要です。「新しいクッキーを生成」をクリックし、サイトのURLを入力、ブラウザのページでログインすると、本アプリがクッキーを生成します。 SponsorBlockのシーンを削除すると、字幕のタイミングがずれることがあります。 カスタムコマンドの使用中は一部の設定を利用できません 多くの動画配信プラットフォームは、音声と映像を別々に配信します。音声形式と映像形式を選択し、1つの動画として結合できます。 どのように機能しますか? クイックダウンロード 動画タイトルサンプルテキスト 字幕 字幕をダウンロード 字幕の言語 SDカードフォルダ 自動生成の字幕 自動生成された字幕をダウンロードする 動画制作者サンプルテキスト クリア ショートカットを編集 Telegram チャンネル Matrix スペース 言語、字幕の埋め込み、自動字幕 ログをコピー 追加 ショートカット コマンドテンプレートの作成に使用できるカスタムショートカットを編集します。 実行中のタスク ログを表示 %.2f GB %.2f MB 破棄 適用 字幕ファイルを埋め込むため、動画を mkv コンテナに多重化しなおします。VLC Media Player などの互換アプリから、字幕を埋め込んだ動画を視聴できます。 共有 安定版 プレビュー版 プレリリース版をインストールすると、新機能や変更を試験的に試すことができます。 \n \nこれらのバージョンには不安定な部分がありますので、問題が発生すれば遠慮なくフィードバックを送り、アプリの改善にご協力ください。 更新対象 自動更新 自動更新を有効にする 名前の変更 ダウンロード済みのメディアなし ベータ 実験的な機能を有効にしますか? 形式の選択ページでクリップ動画を作成する ありがとうございます! GitHubビルドに切り替える 了解 わかった 機能を利用できません 優先する音声形式 上限なし 最低ビットレート 形式の並び替え yt-dlpの-Sオプションで形式を並び替える インポート タイトル 音声形式 このダウンロード機能はFFmpegによるもので、選択した動画中の範囲をダウンロードします。この機能はまだ実験的で、切り抜きは完全に正確ではなく、あらゆる形式に対応したものでもなく、またダウンロード速度が遅くなる場合があります。 自動更新は%1$sビルドでは利用できません。お使いのデバイスに%1$sがインストールされていない場合、または Seal の今後の新機能をプレビューしたい場合は、%2$sを検討してください。 開発者からのメッセージ クリップ動画を作成 開始 終了 カスタムコマンドのタスクなし フィードバック スポンサー アプリ内に保存したクッキーをすべて削除しますか? 音質が複数あるときにビットレートを制限する 音質 すべてのクッキーを消去 スポンサー GitHubでスポンサーになって本アプリを支援する Sealはいつでも無料で、誰でも使えるオープンソースです。もし気に入っていただけたなら、GitHubで私のスポンサーになることを検討してください! 内部ディレクトリーに一時ファイルを保存する yt-dlp を更新 コマンド ダウンロードの種類 古い形式 品質 タップして設定 自動 カスタム カスタムコマンド用フォルダ インターネット接続にプロキシを使用する プロキシ 形式の設定 yt-dlp は動画をダウンロードするための強力なコマンドラインツールです。Seal は、直感的な GUI、通常のコマンドのプリセット、その他の追加機能を提供することで、yt-dlp をより使いやすくします。 \n \nyt-dlp の高度な使用のために、Seal ではカスタムコマンドテンプレートをターミナルと同じように直接作成、保存、実行することができます。 \n \nカスタムコマンドを使用する場合、ほとんどの GUI オプションや機能は無効になります。 動画は %1$d 個のチャプターに分割されます ファイルにエクスポート アプリはダウンロード状況や進捗状況に関する通知を送信するために権限の許可が必要です。 フォルダを選択 動画を分割 他のアプリに共有するため MP4 (H.264) 形式を優先 URL から動画をダウンロード 「%1$s」を編集 不明 詳細 字幕を変換 互換性アプリで視聴するため AV1、VP9、H.265 形式を優先 おっと!問題が発生しました カスタムコマンド使用時の出力ディレクトリを指定する プリセット 開始 User-Agent ヘッダー 新しいダウンロードタスク コピーして終了 字幕を別の形式に変換する 出力テンプレート 出力するファイル名のテンプレートを指定します 無効 無効化 %d 項目 展開 タップで新規クッキーの生成用のウェブページを開く: %1$sのテンプレートを削除しますか? 通知を有効にしますか? ダウンロード記録を消去しますか? ダウンロードの重複を回避するために、ダウンロードした動画のIDを記録します ダウンロードを記録 メタデータの埋め込み メタデータと動画サムネイルを音声ファイルに埋め込む %1$d 項目をすべて表示 ここに保存されます: %1$s 項目を記録ファイルから削除しますか? 互換性を確かにするため、ファイル名を特定の文字に制限する ファイル名を制限 保存 サイト IPv4 を強制 形式の並び替えを使用 システムの設定に従う すべての接続をIPv4にします ファイルを編集 再生リストの題名 必須 字幕ファイルを保持 1度許可 常に許可 許可しない 複数の音声ストリームを1つのファイルへと結合する 複数の音声ストリームを結合 モバイル接続でダウンロードしますか (従量課金の場合)? すべての言語で自動翻訳された字幕をダウンロードできます。これらの字幕は不正確で理解しにくいことがあります。 自動翻訳された字幕 なし 前回の選択を使用 表示 次回のダウンロード用に記憶 初期化 字幕を検索 いいえ 字幕の言語を更新しますか? 以下の言語が、次回のダウンロード時の優先候補に追加されます: 自動で形式を選択してダウンロードする字幕の言語を指定します。カンマ区切り。 ダウンロード一覧から検索 検索 動画がダウンロードされました。これが予期しない動作ならダウンロードアーカイブをご確認ください。 ダウンロード履歴に %1$s 個をインポートしました エクスポート ダウンロード履歴から %1$s 個をエクスポート中。ダウンロードしたファイルと設定はバックアップされません。 ダウンロード済みファイルはインポートできません。手動で再ダウンロードしてください インポート 完全バックアップ 外観と動的変化 バックアップの種類 ファイル クリップボード ダウンロード履歴 エクスポート先 インポート元 ダウンロード履歴をインポートしますか? ダウンロード履歴をエクスポートしますか? 再ダウンロード 互換性改善のため動画をMKVコンテナへと再多重化 動画コンテナを再多重化 %2$dのサイトから%1$d個のクッキーがあります 毎日 毎週 毎月 すべての言語 プリセット 形式、字幕を選択し、調整 %1$s を優先 形式の設定を使い自動ダウンロード 最良の形式でダウンロード キューにタスクを追加 %d本の動画 %d個の音声 再生リスト 続行 プリセットを編集 ダウンロードボタンをタップ、またはこのアプリへの動画リンクを共有しダウンロードを開始します ここからダウンロード一覧を検索できます ダウンロード済み すべて ダウンロードキュー 再開 削除 メディアの情報 問題集積所 トラブルシューティング 一般的なエラーの修正、既知の問題の確認 エラーですか?問題を報告する前に、問題集積所を検索してください。多くの一般的な問題への対処法は既に文書化済みです。 ================================================ FILE: app/src/main/res/values-ji/strings.xml ================================================ היט ווי אַודיאָ היט טאַמנייל דער לינק קען נישט זיין ליידיק אראפקאפיע און היט אַודיאָ אַנשטאָט פון ווידעא היט ווידעא טאַמנייל ווי אַ טעקע ניצן די לעצטע ווערסיע פון yt-dlp דערלויבעניש געלייקנט אראפקאפיע פאַרטיק קען נישט אראפקאפיע טעקע אראפקאפיע \"%1$s\" קען נישט באַקומען ווידעא אינפֿאָרמאַציע אַן עקסיסטירטע אראפקאפיע אַרבעט איז שוין פליסנדיק פּאַפּ די URL Yt-dlp ווערסיע אַפּדייט yt-dlp אַראָפּנעמען? באַשטעטיקט אַודיאָ לינק קאַפּיד צו די קליפּבאָרד ווידעא סעטטינגס אַלגעמיינע, פֿאָרמאַט, אייגן באַפֿעל אָפּלאָדירן אַלגעמיינע ווייַז שפּראַך קען נישט ינסטאַלירן די לעצטע yt-dlp ווערסיע. ביטע מאַכן זיכער אַז איר זענט קאָננעקטעד צו די אינטערנעט. באַשטעטיק ווייַז שפּראַך דריקט צו ינסטאַלירן די לעצטע yt-dlp ווערסיע קען נישט גלייַכן די URL אין די קליפּבאָרד אַראָפּנעמען \"%1$s\" פֿון דיין דאַונלאָודינג געשיכטע פֿאַר גוט? באקומט ווידעא אינפארמאציע… ביטול דאַונלאָודז ווערסיע ווידיאו אנגעצינדן נעם אראפ צירוק אויסגעלאשן ================================================ FILE: app/src/main/res/values-kab/strings.xml ================================================ ================================================ FILE: app/src/main/res/values-km/strings.xml ================================================ រក្សាទុកជាអូឌីយោ ការកំណត់ ដោនឡូដ បញ្ជាប់មិនអាចទទេឡើយ ដោនឡូដនិងរក្សាទុកអូឌីយោ, ជួសវីដេអូ រក្សាទុករូបក្របវីដេអូជាឯកសារ ថតវីដេអូ រក្សាទុករូបក្រប ទូទៅ, ហ្វ័រម៉ាត, ឃ្លាបញ្ជាផ្ទាល់ខ្លួន កំពុងប្រើកំណែចុងក្រោយបំផុតនៃ yt-dlp មិនអាចដំឡើងកំណែ yt-dlp ចុងក្រោយបង្អស់បានទេ។ សូមប្រាកដថាអ្នកបានតភ្ជាប់អ៊ីនធឺណិត។ សិទ្ធិអនុញ្ញាតត្រូវបានបដិសេធ ដោនឡូដសម្រេច មិនអាចដោនឡូដឯកសារ ដោនឡូដ \"%1$s\" មិនអាចរួបព័ត៌មានវីដេអូទេ កំពុងរួបព័ត៌មានវីដេអូ… ទូទៅ ភាសាអេក្រង់បង្ហាញ កំណត់ភាសាអេក្រង់បង្ហាញ កិច្ចការដោនឡូដមានស្រាប់កំពុងរត់ហើយ វីដេអូ ឆែកឆេរ ក្រេឌីត ក្រេឌីត និងសុសវែរ libre បិទភ្ជាប់យូអអិលពីឃ្លីបប៊ត មិនអាចផ្គូផ្គងយូអអិលនៅក្នុងឃ្លីបប៊តបានទេ កំណែនៃ Yt-dlp អាប់ដេត yt-dlp ដកចេញឬ? ដក \"%1$s\" ចេញពីប្រវត្តិដោនឡូដរបស់អ្នកពិតឬ? អះអាង បោះបង់ ដោនឡូដ អូឌីយោ បញ្ជាប់បានចម្លងទៅឃ្លីបប៊ត បើកបញ្ជាប់ ដកចេញ លុបឯកសារ អំពី កំណែ, មតិកែលម្អ, ស្វ័យអាប់ដេត ត្រឡប់ កំណែ ឆែកកំណត់ហេតុផ្លាស់និងកំណែថ្មី ការចេញផ្សាយចុងក្រោយ ឆែកឃ្លាំងផ្ទុកនៃ GitHub និងអានខ្ញុំ ឃ្លាបញ្ជាផ្ទាល់ខ្លួន រត់ឃ្លាបញ្ជ yt-dlp ជាមួយនឹងពុម្ពគំរូផ្ទាល់ខ្លួន ពុម្ពគំរូឃ្លាបញ្ជា កែ របាយការណ៍កំហុសបានចម្លងទៅឃ្លីបប៊ត ទីតាំងធាតុចេញនិងយូអអិលក៏នឹងត្រូវបានបន្ថែមដោយកម្មវិធី។ ចាប់ផ្តើមប្រតិបត្តិឃ្លាបញ្ជា បោះពុម្ពសារលម្អិត នៅពេលកំពុងដោនឡូដ ស្បែកងងឹត ប្រព័ន្ធ កម្រិតខ្ពស់ ធាតុចេញលម្អិត អេក្រង់បង្ហាញ ស្បែកងងឹត, ពណ៌ថាមវន្ត, ភាសា បើក បិទ បោះបង់ កំណត់រចនាសម្ព័ន្ធមុនដោនឡូដ កំណត់រចនាសម្ព័ន្ធបុរិមាចំណូលចិត្តមុនពេលដោនឡូត លៃតម្រូវដោនឡូដនេះ រូបក្រប បិទភ្ជាប់ យោងទម្លាប់ Yt-dlp បំប្លែងហ្វ័រម៉ាតអូឌីយោ ឥតបំប្លែងសោះ បំប្លែងទៅ %1$s ហ្វ័រម៉ាត កូដការឯកសារអូឌីយោឡើងវិញនឹងបណ្តាលឱ្យបាត់បង់គុណភាពអូឌីយោ និងបង្កើនទំហំឯកសារ។ គុណភាពវីដេអូ គុណភាពខ្ពស់ ដាក់កម្រិតគុណភាពវីដេអូនៅពេលដែលមានច្រើន មិនបានបញ្ជាក់ (បុរេជម្រើស) ហ្វ័រម៉ាតវីដេអូដែលបានពេញចិត្ត ហ្វ័រម៉ាតដែលពេញចិត្ត នៅពេលបានផ្ដល់ឱ្យច្រើនចំនួន ហ្វ័រម៉ាតវីដេអូ បំប្លែង ដោនឡូដ បិទ កុំបង្ហាញខ្លួនម្ដងទៀត មគ្គុទេសក៍អត្ថជន បើកការកំណត់ ចុច “បិទភ្ជាប់” ដើម្បីយកបញ្ជាប់វីដេអូពីឃ្លីបប៊តរបស់អ្នក។ បន្ទាប់មកចុច “ដោនឡូដ” ក្រោយពីការលៃតម្រូវការកំណត់របស់វា។ កំពុងរត់ឃ្លាបញ្ជាផ្ទាល់ខ្លួន… សូមកំណត់ទម្លាប់ថ្មរបស់កម្មវិធីនេះទៅជា \"មិនបានរឹតត្បិត\" នៅក្នុងការកំណត់ប្រព័ន្ធ ដើម្បីទាញយកនៅផ្ទៃខាងក្រោយ។ មិនអាចផ្គូផ្គងយូអអិលពីមាតិកាដែលបានចែករំលែក ការជូនដំណឹងដោនឡូដ ចុចដើម្បីដំឡើងកំណែ yt-dlp ចុងក្រោយបំផុត ពិនិត្យនិងគ្រប់គ្រងដោនឡូដក្នុងកម្មវិធី រួមទាំងឯកសារវីដេអូនិងអូឌីយោ។ សូមឆែកមើលការកំណត់ដោនឡូដ ហើយត្រូវប្រាកដថាអ្នកមានកំណែចុងក្រោយបំផុតរបស់ yt-dlp មុនពេលប្រើវា។ ដោនឡូដបញ្ជីចាក់ ដោនឡូដពហុវីដេអូពីបញ្ជីចាក់ បុរេជម្រើស ដោនឡូដ រម្លឹកឯកសារបានដោនឡូដនិងវឌ្ឍនភាព បញ្ជាប់វីដេអូ ដោនឡូដសម្រេច។ ប៉ះដើម្បីបើក។ ដោនឡូដពហុសរសៃ ដោនឡូដផ្នែកជាច្រើននៃវីដេអូ M3U8/MPD ស្របគ្នា %d សរសៃក៏នឹងត្រូវបានប្រើ ដើម្បីទាញយកវីដេអូដើម DASH/HLS ក្នុងពេលដំណាលគ្នា។ ជម្រើស ការកំណត់បន្ថែម កំពុងអានបញ្ជាប់វីដេអូពីមាតិកាដែលបានចែករំលែក… បង្ហាញសកម្មភាពបន្ថែមទៀត ជូនដំណឹងនៃឯកសារដែលបានដោនឡូដនិងវឌ្ឍនភាព កំពុងរួបព័ត៌មានបញ្ជីចាក់… បញ្ជីចាក់ជ្រើសរើស ចាប់ផ្ដើម បញ្ជប់ រ៉េងសន្ទស្សន៍មិនត្រឹមត្រូវ កំពុងដោនឡូដបញ្ជីចាក់ (%1$d/%2$d)… ថតអូឌីយោ ទីតាំងផ្នែកដោនឡូដ បញ្ជាក់រ៉េងនៃវីដេអូដែលត្រូវទាញយកពីបញ្ជីចាក់ \"%3$s\" (ពី %1$d ដល់ %2$d)។ រើសទីកន្លែងទុកដាក់ឯកសារវីដេអូ និងអូឌីយោ រក្សាទុកនៅទីថតរង រក្សាទុកឯកសារនៅក្នុងថតដែលបានឈ្មោះទីទៃគ្នា បញ្ហាសិទ្ធិអនុញ្ញាតផ្ទុក ទីថតខាងក្រៅ Download/ និង Documents/ មិនត្រូវបានគាំទ្រទេ បានបញ្ចប់ បើកឯកសារ កម្រិតគុណភាពវីដេអូ បាននាំចេញពុម្ពគំរូ %1$d ឯកសារវីដេអូ %1$d, អូឌីយោ %2$d ចំណាត់ថ្នាក់នៃ SponsorBlock ពិនិត្យបច្ចុប្បន្នភាព ការកំណត់រចនាសម្ព័ន្ធថ្ម ពុំអើពើបង្កើនប្រសិទ្ធភាពថ្ម សម្រាប់កម្មវិធីនេះដើម្បីដោនឡូដនៅផ្ទៃខាងក្រោយ Seal គឺកំពុងតែដោនឡូដ… កំហុសមិនស្គាល់ បកប្រែ ជួយបកប្រែកម្មវិធីនេះលើ Hosted Weblate បុព្វបទ បង្កប់ចំណងជើងរង កំពុងដោនឡូដ បានបោះបង់ កំពុងរួបព័ត៌មាន ចាប់ផ្ដើមឡើងវិញ កំហុស ចម្លងបញ្ជាប់ របាយការណ៍កំហុស ទំហំឯកសារវីដេអូ នាំចេញទៅឃ្លីបប៊ត នាំចូលពីឃ្លីបប៊ត បាននាំចូលពុម្ពគំរូ %1$d ភារកិច្ចដោនឡូដ %1$d ទើបតែបន្ថែម ប្រើ aria2c ជាការីដោនឡូដខាងក្រៅ បង្កប់សុសរងទៅក្នុងវីដេអូ ប្រសិនបើមាន ពុម្ពគំរូថ្មី ================================================ FILE: app/src/main/res/values-kmr/strings.xml ================================================ ================================================ FILE: app/src/main/res/values-kn/strings.xml ================================================ ಡಿಲೀಟ್ ಕ್ಯಾನ್ಸಲ್ ಅಡ್ವಾನ್ಸಡ್ ಡೀಟೈಲ್ಡ್ ಔಟ್ಪುಟ್ ಲಿಂಕ್ ಕೋಪಿಎಡ್ ಟು ಕ್ಲಿಪ್ಬೋರ್ಡ್ ಕ್ರೆಡಿಟ್ಸ್ ಅಂಡ್ ಲಿಬ್ರೆ ಸಾಫ್ಟ್ವೇರ್ ಚೆಕ್ ದಿ ಗಿತುಬ್ ರೆಪೊಸಿಟರಿ ಅಂಡ್ ದಿ ರೆಡ್ಮ್ ರಿಮೋವ್ ರನ್ yt-dlp ಕಮಾಂಡ್ ವಿಥ್ ಕಸ್ಟಮ್ ಟೆಂಪ್ಲೆಟ್ ಕ್ಲಿಕ್ ಟು ಇನ್ಸ್ಟಾಲ್ ದಿ ಲೇಟೆಸ್ಟ್ yt-dlp ವರ್ಷನ್ ಡೌನ್ಲೋಡ್ ಅಂಡ್ ಸೇವ್ ಆಡಿಯೋ, ಇನ್ಸ್ಟೆಡ್ ಆಫ್ ವಿಡಿಯೋ ಕುಡ್ ನಾಟ್ ಮ್ಯಾಚ್ ದಿ ಯುಆರ್ಎಲ್ ಇನ್ ದಿ ಕ್ಲಿಪ್ಬೋರ್ಡ್ ಆನ್ ಜನರಲ್,ಫಾರ್ಮ್ಯಾಟ್,ಕಸ್ಟಮ್ ಕಮಾಂಡ್ Yt-dlp ಯೂಸೇಜ್ ರೆಫೆರೆನ್ಸಸ್ ಎಡಿಟ್ ಪೇಸ್ಟ್ ಯುಆರ್ಎಲ್ ಫ್ರಮ್ ಕ್ಲಿಪ್ಬೋರ್ಡ್ ವಿಡಿಯೋ ಕ್ವಾಲಿಟಿ ಡಾರ್ಕ್ ಥೀಮ್, ಡೈನಾಮಿಕ್ ಕಲರ್,ಲ್ಯಾಂಗುವೇಜಸ್ ಕಾನ್ಫಿಗರ್ ಬಿಫೋರ್ ಡೌನ್ಲೋಡ್ ಲುಕ್ ಫಾರ್ ಛಂಗೆಲೊಗ್ಸ್ ಅಂಡ್ ನ್ಯೂ ವೆರಿಸಿಯೋನ್ಸ್ Yt-dlp ವರ್ಷನ್ ಡಿಸ್ಪ್ಲೇ ಆಫ್ ಎರರ್ ರಿಪೋರ್ಟ್ ಕಪಿಡ್ ಟು ಕ್ಲಿಪ್ಬೋರ್ಡ್ ಬ್ಯಾಕ್ ಡೌನ್ಲೋಡ್ಸ್ ಲೇಟೆಸ್ಟ್ ರಿಲೀಸ್ ವರ್ಷನ್ ಕುಡ್ ನಾಟ್ ಇನ್ಸ್ಟಾಲ್ ದಿ ಲೇಟೆಸ್ಟ್ yt-dlp ವರ್ಷನ್. ಪ್ಲೀಸ್ ಮೇಕ್ ಸುರೆ ಯು ಆರ್ ಕನೆಕ್ಟೆಡ್ ಟು ದಿ ಇಂಟರ್ನೆಟ್. ಜನರಲ್ ಪ್ರಿಂಟ್ ಡೀಟೈಲ್ಡ್ ಮೆಸ್ಸಗೆ ವೆನ್ ಡೌನ್ಲೋಅಡಿಂಗ್ ಡೌನ್ಲೋಡ್ ಫೀನಿಶೆಡ್ ಕನ್ವರ್ಟ್ ಆಡಿಯೋ ಫಾರ್ಮ್ಯಾಟ್ ವೀಡಿಯೊ ಫೋಲ್ಡರ್ ದಿ ಲಿಂಕ್ ಕೆನಾಟ್ ಬಿ ಎಂಪ್ಟಿ ಪೇಸ್ಟ್ ರಿಮೋವ್ \"%1$s\" ಫ್ರಮ್ ಯುವರ್ ಡೌನ್ಲೋಡ್ ಹಿಸ್ಟರಿ ಫಾರ್ ಗುಡ್? ಡೌನ್ಲೋಡ್ ಸೇವ್ ವಿಡಿಯೋ ಥಂಬ್ನೇಲ್ ಆಸ್ ಆ ಫೈಲ್ ಸ್ಟಾರ್ಟ್ ಎಕ್ಸಿಕ್ಯೂಟಿಂಗ್ ಕಮಾಂಡ್ ಚೆಕಡ್ ಅಬೌಟ್ ಓಪನ್ ಲಿಂಕ್ ರೇ-ಎನ್ಕೋಡಿಂಗ್ ಆಡಿಯೋ ಫೈಲ್ಸ್ ವಿಲ್ ಕಾಸ್ ಲೋಸ್ ಇನ್ ಆಡಿಯೋ ಕ್ವಾಲಿಟಿ ಅಂಡ್ ಇನ್ಕ್ರೀಜ್ ಇನ್ ಫೈಲ್ ಸೈಜ್. ಅನ್ಕನ್ವೆರ್ಟೆಡ್ ವರ್ಷನ್, ಫೀಡ್ಬ್ಯಾಕ್, ಆಟೋ ಅಪ್ಡೇಟ್ ತುಂಬಿನೈಲ್ ಕುಡ್ ನಾಟ್ ಡೌನ್ಲೋಡ್ ಫೈಲ್ ಕಮಾಂಡ್ ಟೆಂಪ್ಲೆಟ್ ಡೌನ್ಲೋಡ್ \"%1$s\" ಡಾರ್ಕ್ ಥೀಮ್ ಕ್ಯಾನ್ಸಲ್ ಕಾನ್ಫಿಗರ್ ಬಿಫೋರ್ ಡೌನ್ಲೋಡ್ ಕನ್ಫರ್ಮ್ ಪರ್ಮಿಷನ್ ಡೇನಿಎಡ್ ಸೆಟ್ ಡಿಸ್ಪ್ಲೇ ಲ್ಯಾಂಗ್ವೇಜ್ ಸಿಸ್ಟಮ್ ರಿಮೋವ್? ಡಿಸ್ಪ್ಲೇ ಲ್ಯಾಂಗ್ವೇಜ್ ಕಸ್ಟಮ್ ಕಮಾಂಡ್ ಎನ್ ಎಕ್ಸಿಸ್ಟಿಂಗ್ ಡೌನ್ಲೋಡ್ ಟಾಸ್ಕ್ ಇಸ್ ಆಲ್ರೆಡಿ ರನ್ನಿಂಗ್ ಅಡ್ಜಸ್ಟ್ ದಿಸ್ ಡೌನ್ಲೋಡ್ ವಿಡಿಯೋ ಕುಡ್ ನಾಟ್ ಫೇತ್ಚ್ ವಿಡಿಯೋ ಇನ್ಫೋ ಸೆಟ್ಟಿಂಗ್ಸ್ ಯೂಸಿಂಗ್ ದಿ ಲೇಟೆಸ್ಟ್ ವರ್ಷನ್ ಆ yt-dlp ಸೇವ್ ಆಸ್ ಆಡಿಯೋ ಸೇವ್ ಥಂಬ್‌ನೇಲ್ ಕನ್ವರ್ಟ್ ಟು %1$s ಫಾರ್ಮ್ಯಾಟ್ ಅಪ್ಡೇಟ್ yt-dlp ಆಡಿಯೋ ಔಟ್ಪುಟ್ ಪಥ ಅಂಡ್ ಯುಆರ್ಎಲ್ ವಿಲ್ ಬಿ ಆಡೆಡ್ ಬೈ ದಿ ಅಪ್. ಕ್ರೆಡಿಟ್ಸ್ ಫೆಟಚಿಂಗ್ ವಿಡಿಯೋ ಇನ್ಫೋ… ಕ್ಲೋಸ್ ಯುಸರ್ ಗೈಡ್ ಪ್ರಿಫರ್ಡ ವಿಡಿಯೋ ಫಾರ್ಮ್ಯಾಟ್ ಟೇಕ್ ಆ ಲುಕ್ ಅಟ್ ದಿ ಡೌನ್ಲೋಡ್ ಸೆಟ್ಟಿಂಗ್ಸ್ ಅಂಡ್ ಮೇಕ್ ಸುರೆ ಯು ಹಾವೇ ದಿ ಲೇಟೆಸ್ಟ್ ವರ್ಷನ್ ಆ yt-dlp ಬಿಫೋರ್ ಉಸಿಂಗ್ ಇಟ್. ಪ್ರಿಫರ್ಡ ಫಾರ್ಮ್ಯಾಟ್ ವೆನ್ ಮಲ್ಟಿಪಲ್ ಆರ್ ಪ್ರೊವಿಡೆಡ್ ಡೌನ್ಲೋಡ್ ಪ್ಲೇಲಿಸ್ಟ್ ಕ್ಲಿಕ್ \"ಪೇಸ್ಟ್\" ಟು ಗೆಟ್ ವಿಡಿಯೋ ಲಿಂಕ್ ಫ್ರಮ್ ಯುವರ್ ಕ್ಲಿಪ್ಬೋರ್ಡ್. ಕನ್ವರ್ಟ್ ವಿಡಿಯೋ ಲಿಂಕ್ ನೋಟಿಫ್ಯ್ ಆ ಡೌನ್ಲೋಅಡೆಡ್ ಫೈಲ್ಸ್ ಅಂಡ್ ಪ್ರೋಗ್ರೆಸ್ ಡೌನ್ಲೋಡ್ ನೋಟಿಫಿಕೇಶನ್ ಡೌನ್ಲೋಡ್ ಥೇನ್ ಕ್ಲಿಕ್ \"ಡೌನ್ಲೋಡ್\" ಆಫ್ಟರ್ ಅದ್ಜುಸ್ಟಿಂಗ್ ಇಟ್ಸ್ ಸೆಟ್ಟಿಂಗ್ಸ್. ಓಪನ್ ಸೆಟ್ಟಿಂಗ್ಸ್ ಚೆಕ್ ಅಂಡ್ ಮ್ಯಾನೇಜ್ ಇನ್-ಅಪ್ಪ ಡೌನ್ಲೋಡ್ಸ್, ಇನ್ಕ್ಲೂಡಿಂಗ್ ವೀಡಿಯೋಸ್ ಅಂಡ್ ಆಡಿಯೋ ಫೈಲ್ಸ್. ವಿಡಿಯೋ ಫಾರ್ಮ್ಯಾಟ್ ಅಡಿಷನಲ್ ಸೆಟ್ಟಿಂಗ್ಸ್ ಲಿಮಿಟ್ ದಿ ವಿಡಿಯೋ ಕ್ವಾಲಿಟಿ ವೆನ್ ಮಲ್ಟಿಪಲ್ ಆರ್ ಪ್ರೆಸೆಂಟ್ ಡೌನ್ಲೋಡ್ ಮಲ್ಟಿಪಲ್ ವೀಡಿಯೋಸ್ ಫ್ರಮ್ ಆ ಪ್ಲೇಲಿಸ್ಟ್ ಆಪ್ಷನ್ ಅನೆಬೆಲ್ ಟು ಮ್ಯಾಚ್ ಯುಆರ್ಎಲ್ ಫ್ರಮ್ ಶಾರ್ಡ್ ಕಂಟೆಂಟ್ ಮಲ್ಟಿ-ತ್ರೆಡ್ಗ್ದ್ ಡೌನ್ಲೋಡ್ ಡೌನ್ಲೋಡ್ ಮೋರ್ ಪಾರ್ಟ್ಸ್ ಆ ಎಂ೩ಉ೮/ಮೈಫ್ಡ್ ವೀಡಿಯೋಸ್ ಇನ್ ಪ್ಯಾರಲಲ್ ಶೋ ಮೊರೆ ಆಪ್ಷನ್ ಡೋಂಟ್ ಶೋ ಅಪ್ ಅಗೈನ್ ಡೌನ್ಲೋಡ್ ಫೀನಿಶೇಡ್. ಟಾಪ್ ಟು ಓಪನ್. ಡೌನ್ಲೋಡ್ ಪ್ಲೀಸ್ ಸೆಟ್ ಬ್ಯಾಟರಿ ಯೂಸೇಜ್ ಆ ದಿಸ್ ಅಪ್ಪ ಟು \"ಉಂರೆಸ್ಟ್ರಿಕ್ಟ್ದ್\" ಇನ್ ದಿ ಸಿಸ್ಟಮ್ ಸೆಟ್ಟಿಂಗ್ಸ್ ಟು ಡೌನ್ಲೋಡ್ ಇನ್ ದಿ ಬ್ಯಾಕ್ಗ್ರೌಂಡ್. ನಾಟ್ ಸ್ಪೆಸಿಫೈಡ್ (ಡೀಫಾಲ್ಟ್) %d ಥ್ರೆಡ್(ಸ್) ವಸ್ಲ್ಡ್ ಬಿ ಉಸೆದ್ ಟು ಡೌನ್ಲೋಡ್ ಡ್ಯಾಶ್/ಹ್ಲ್ಸ್ ನೇಟಿವ್ ವಿಡಿಯೋ ಕಾಂಕರೆಂಟಿಲಿ. ರನ್ನಿಂಗ್ ಕಸ್ಟಮ್ ಕಮಂಡ್ಸ್… ಡೀಫಾಲ್ಟ್ ರೀಡಿಂಗ್ ವಿಡಿಯೋ ಲಿಂಕ್ ಫ್ರಮ್ ಶಾರ್ಡ್ ಕಂಟೆಂಟ್… ಬೆಸ್ಟ್ ಕ್ವಾಲಿಟಿ ================================================ FILE: app/src/main/res/values-ko/strings.xml ================================================ 설정 파일을 다운로드할 수 없습니다 최신 yt-dlp 버전을 설치할 수 없습니다. 인터넷에 연결되어 있는지 확인해주세요. \"%1$s\" 다운로드 동영상 정보를 가져올 수 없습니다 언어 URL 붙여넣기 오디오 다운로드 기록에서 \"%1$s\"을(를) 삭제할까요\? 최신 버전의 yt-dlp 사용 중 동영상 썸네일을 파일로 저장 동영상 대신 오디오 다운로드 및 저장 언어 설정 삭제하실 건가요\? 다운로드 파일 삭제 정보 yt-dlp의 최신버전을 설치하려면 클릭하세요 클립보드의 URL을 일치시킬 수 없습니다 Yt-dlp 버전 확인 취소 링크 열기 목록에서 제거 최신 릴리스 체크됨 고급 옵션 버전, 피드백, 자동 업데이트 버전 변경 로그 및 새 버전 찾기 뒤로 GitHub 저장소 및 사용방법 확인 동영상 편집 명령 실행 시작 크레딧 크레딧 및 자유 소프트웨어 사용자 지정 명령 맞춤 템플릿으로 yt-dlp 명령어 실행 일반 클립보드에 복사된 링크 다운로드 작업이 이미 실행 중입니다 오디오로 저장 썸네일 저장 일반, 서식, 사용자 지정 명령 동영상 폴더 명령 템플릿 다운로드 링크는 비워둘 수 없습니다 동영상 정보를 가져오는 중… 권한이 거부되었습니다 다운로드가 완료되었습니다 오디오 형식 변환 여러 개가 제공될 때 선호하는 형식 비디오 품질 최고 품질 여러 개가 있는 경우 비디오 품질을 제한합니다 비디오 링크 설정 열기 비디오 및 오디오 파일을 저장할 위치 선택 하위 디렉터리에 저장 다운로드 디렉터리 배터리 구성 동영상에 제공되는 자막 포함 (사용 가능한 경우) 클립보드에서 비디오 링크를 가져오려면 \"붙여넣기\"를 클릭하세요. 변환되지 않은 켜짐 어두운 테마 다중 스레드 다운로드 꺼짐 붙여넣기 Yt-dlp 사용 참조 출력 경로와 URL은 앱에서 추가됩니다. 비디오 형식 변환 다운로드 사용자 설명서 닫음 다시 표시하지 않음 설정을 조정한 후 \"다운로드\"를 클릭하세요. 비디오 및 오디오 파일을 포함한 앱 내 다운로드를 확인하고 관리합니다. 다운로드 설정을 살펴보고 yt-dlp의 최신 버전이 있는지 확인한 후 사용하십시오. 더 많은 M3U8/MPD 비디오 부분들을 동시에 다운로드 %d 스레드는 DASH/HLS 기본 비디오를 동시에 다운로드하는 데 사용됩니다. 자막 포함 새 템플릿 라벨 제거\? 명령 템플릿에서 %1$s을(를) 제거하시겠습니까\? 다운로드 알림 잘못된 인덱스 범위 오디오 폴더 알수없는 오류 상세 출럭 다운로드 시 자세한 메시지 출력 디스플레이 어두운 테마, 동적 색상, 언어 취소 다운로드하기 전 설정하기 다운로드 전에 설정 변경하기 이 다운로드 조정 클립보드에 오류 보고서가 복사됨 %1$s 로 변환 형식 지정되지 않음 (기본값) 선호하는 비디오 형식 재생 목록 다운로드 기본 다운로드 다운로드한 파일 및 진행 상황 알림 다운로드가 완료되었습니다. 열려면 탭하세요. 사용자 지정 명령 실행 중… 백그라운드에서 다운로드하려면 시스템 설정에서 이 앱의 배터리 항목을 \"제한없음\"으로 설정하세요. 옵션 추가 설정 공유 콘텐츠의 URL을 일치시킬 수 없습니다 공유 콘텐츠에서 비디오 링크를 읽는 중… 추가 작업 표시 다운로드한 파일 및 진행 상황 알림 재생목록 정보를 가져오는 중… 재생 목록 선택 각 필드 이름으로 지정된 폴더에 파일 저장 저장소 권한 문제 Download/ 및 Documents/ 이외의 외부 디렉토리는 지원되지 않습니다 Seal 다운로드 중… 번역 Hosted Weblate에서 이 앱의 번역을 도울수 있습니다 접두사 시스템 오디오 파일을 다시 인코딩하면 오디오 품질이 저하되고 파일 크기가 커집니다. 썸네일 재생 목록에서 여러 비디오 다운로드 재생 목록 \"%3$s\" (%1$d ~ %2$d)에서 다운로드할 비디오 범위를 지정하십시오. 시작 재생 목록(%1$d/%2$d)을 다운로드하는 중입니다… 이 앱이 백그라운드에서 다운로드하려면 배터리 최적화를 해제하세요 비디오 (오디오 없음) 제안 재시작 템플릿 선택 동적 색상 속도 제한, 다운로더, 쿠키 재생목록 \"%1$s\"에서 다운로드할 동영상을 선택 %1$d 선택됨 포함된 이미지를 정사각형으로 자르기 모두 선택 다운로드에 Netscape 형식의 쿠키 사용 배경화면의 색상을 앱 테마에 적용 속도 제한 완료 다중 선택 모드 %1$d개의 임시 파일 삭제 최저 품질 쿠키 다운로드 중 미리 보기 표시 안 함 프라이버시 다운로드가 진행 중입니다… 다운로드 작업이 취소됨 깃허브 이슈 버그 보고서 또는 기능 요청을 위해 문제 제출 클립보드에 복사된 정보 대기 중 aria2c를 외부 다운로더로 사용 업데이트 임시 파일 지우기 임시 디렉토리에서 모든 임시 파일 삭제 임시 파일을 사용하여 취소된 다운로드를 다시 시작할 수 있습니다. 이 파일들을 모두 삭제하시겠습니까? \n \n%1$s에서 이 파일들을 접근할 수 있습니다 시크릿 모드 다운로드 기록 사용 안 함 고대비 어두운 테마 잘못된 입력 파일 형식, 비디오 품질, 자막 사용할수 없음 아트웍 자르기 명령 템플릿 편집 및 관리 %1$d 다운로드 작업 최근에 추가됨 비디오 파일 %1$d개, 오디오 파일 %2$d개 SponsorBlock 범주 업데이트 확인 GitHub에서 최신 버전 자동 확인 데이터 통신 네트워크에 연결된 경우 미디어 다운로드 허용 설정에 따라 모바일 데이터로 다운로드할 수 없습니다 이 파일은 더 이상 사용할 수 없습니다 네트워크 미리보기 비활성화 개인 디렉토리 사용자 지정 명령 사용 숨겨진 디렉터리에 다운로드 취소 됨 %1$d개의 템플릿을 내보냈습니다 %1$d개의 템플릿을 가져왔습니다 다운로드 기록에서 %1$d 항목을 제거하시겠습니까\? 모바일 데이터를 사용하여 다운로드 다운로드 최대 속도 제한 다운로드 중 정보를 가져오는 중 파일 열기 오류 링크 복사 보고서 복사하기 비디오 해상도 비디오 파일 크기 클립보드로 내보내기 클립보드에서 가져오기 SponsorBlock API를 사용하여 비디오에서 세그먼트를 제거하거나 마킹 비디오 파일에서 제거하거나 마킹할 SponsorBlock 범주 지정 현재 버전이 최신입니다 최신 버전으로 업데이트하지 못했습니다 최대 비율 Yt-dlp 버전, 알림, 재생목록 무제한 오디오 품질 선호하는 오디오 형식 최저 전송률 여러 품질이 존재할 때 오디오 비트 전송률 제한 yt-dlp의 -S 옵션으로 형식 정렬 형식 정렬 오디오 형식 다운로드한 미디어 없음 베타 실험 기능을 사용하시겠습니까\? 이 기능을 사용하는 다운로드는 비디오의 선택된 섹션을 다운로드할때 FFmpeg에게 위임됩니다. 이 기능은 아직 실험적이며 자르기가 완전히 정확하지 않습니다. 또한 모든 형식이 이 기능을 지원하는 것은 아니며 다운로드 속도가 느려질 수 있습니다. 형식 선택 페이지에서 비디오 클립 만들기 %.2f MB %.2f GB 형식 선택 안정 미리 보기 채널 업데이트 자동 업데이트 자동 업데이트 사용 Seal은 항상 무료이며 모든 사람을 위한 오픈 소스입니다. 마음에 드신다면 GitHub에서 저를 후원해 주세요! 개발자 메시지 매우 감사합니다! GitHub 빌드로 전환 좋아요 알겠어요 사용할 수 없는 기능입니다 버림 동영상 클립 시작 적용 새 쿠키 생성 텔레그램 채널 행렬 공간 쿠키 사용 \"%1$s\"의 항목를 제거하시겠습니까? 이 사이트를 위해 저장된 쿠키는 삭제되지 않습니다. 어떻게 작동합니까\? 일부 사이트에서 다운로드하려면 계정 인증 정보가 필요합니다. \"새 쿠키 생성\"을 클릭하고 웹 사이트의 URL을 입력한 다음 브라우저 페이지에서 계정으로 로그인하면 앱에서 해당 쿠키를 생성합니다. 대부분의 비디오 스트리밍 플랫폼은 오디오와 비디오를 별도로 제공하므로 오디오 전용 형식을 비디오 전용 형식으로 선택하여 단일 비디오로 병합할 수 있습니다. 사용자 지정 명령을 사용하는 경우 일부 옵션을 사용할 수 없습니다 자막 빠른 다운로드 동영상 제목 샘플 텍스트 동영상 제작자 샘플 텍스트 자막 다운로드 자막 언어 언어, 내장 자막, 자동 캡션 자동 생성된 캡션 다운로드 SD 카드 폴더 자동 캡션 로그 복사 바로 가기 바로 가기 편집 작업 실행 로그 표시 추가 명령 템플릿을 작성하는 데 사용할 수 있는 사용자 지정 바로 가기를 편집합니다. 지우기 자막을 삽입하기 위해 동영상은 mkv 컨테이너로 리먹스됩니다. VLC 미디어 플레이어 등 지원되는 플레이어로 재생할 수 있습니다. 후원하기 다운로드를 시작하기 전에 다운로드할 형식 선택 SponsorBlock 세그먼트를 제거할 때 자막이 누락될 수 있습니다. 새로운 기능과 변경 사항을 미리 보기위해 시험판 빌드를 설치합니다. \n \n이러한 버전에는 약간의 불안정성이 있을 수 있으므로, 향후 앱을 개선하는 데 도움이 되도록 문제가 발생하면 주저하지 말고 피드백을 보내주세요. %1$s 빌드에는 자동 업데이트를 사용할 수 없습니다. 장치에 %1$s이(가) 설치되어 있지 않거나 Seal의 향후 새 기능을 미리 보려면 %2$s를 고려하세요. 사용자 지정 명령 작업 없음 제목 URL에서 동영상 다운로드 앱에 저장된 모든 쿠키를 완전히 삭제하시겠습니까\? 로그 공유 불러옴 이름 바꾸기 모든 쿠키 지우기 GitHub에서 후원하여 이 앱을 지원해주세요 피드백 후원자 내부 디렉토리에 임시 파일 저장 영상은 %1$d개의 장으로 분할됩니다 동영상 분할 자막을 다른 포맷으로 변환 자막 변환 이런! 무언가 잘못되었습니다 복사 및 종료 프록시 인터넷 연결에 프록시 사용 알림을 활성화하나요\? 비활성화 커스텀 명령어 디렉토리 비활성화됨 지원되는 앱으로 재생 시 AV1, VP9, H.265 포맷을 선호 시작 \"%1$s\" 편집 yt-dlp 업데이트 품질 다운로드 상태와 진행도 알림을 표시할 권한이 필요합니다. 폴더 선택기 다른 앱에 공유할 때 MP4(H.264) 포맷을 선호 탭하여 디렉토리 설정 커스텀 명령어를 사용할 때 출력 디렉토리를 설정합니다 레거시 펼치기 새 다운로드 작업 yt-dlp는 영상 다운로드를 위한 강력한 명령줄 도구입니다. Seal은 직관적인 GUI와 일반적인 명령어들을 위한 프리셋들, 기타 추가적인 기능들을 제공함으로써 yt-dlp을 쉽게 사용할 수 있습니다. \n \nyt-dlp의 고급 사용을 위해 Seal은 커스텀 명령어 템플릿을 제작과 저장, 터미널에서처럼 직접 실행할 수도 있습니다. \n \n커스텀 명령어 사용 시, 대부분의 GUI 옵션과 기능들이 비활성화될 것입니다. 다운로드 아카이브를 비울까요? 저장 파일로 내보내기 명령어 정말로 %1$s를 아카이브 파일에서 삭제할까요? 웹사이트 더 알아보기 다운로드 유형 메타데이터 포함하기 프리셋 User-Agent 헤더 호환성을 위해 파일명에 사용 가능한 문자를 제한합니다 필수 %1$d개의 항목 모두 표시 출력 템플릿 자동 항목 %d개 탭하여 새로운 쿠키 생성을 위해 웹페이지 열기: 파일명 제한 파일 편집 중복 다운로드를 방지하기 위해 다운로드한 영상 ID들을 아카이브에 기록합니다 커스텀 메타데이터와 영상 썸네일을 오디오 파일에 포함시킵니다 플레이리스트 제목 다운로드 아카이브 정말로 커스텀 템플릿에서 %1$s를 삭제할까요? 모바일 네트워크로 다운로드하는 것을 허용할까요? 한 파일에 여러 오디오 스트림들을 합칠 수 있게 합니다 알 수 없음 출력 파일명들을 위한 템플릿 지정 한 번 허용하기 형식 정렬 사용하기 항상 허용하기 허용하지 않기 여러 오디오 스트림 합치기 다음과 같이 다운로드가 저장됩니다: 자막 파일 유지하기 형식 선호 설정 시스템 설정 강제 IPv4 모든 연결을 IPv4로 연결합니다 다운로드 검색 검색 자동 번역 자막 다음 다운로드에도 적용하기 보기 & 느낌 이전 선택 사용하기 없음 자동 형식 선택으로 다운로드할 자막들의 언어, 콤마로 분리합니다. 초기화 자막에서 검색 괜찮아요 모든 언어에 대해 자동 번역된 자막들을 다운로드합니다. 이 자막들은 정확하지 않아 이해하기 어려울 수 있습니다. 불러오기 다운로드된 파일들은 불러오지 않으며, 수동으로 다시 다운로드해야 합니다 비디오가 다운로드되었습니다. 이게 예상치 못한 현상이라면, 다운로드 아카이브를 확인해주세요. 불러오기 다운로드 기록을 불러올까요? 인터페이스 & 상호작용 내보내기 전체 백업 백업 유형 내보내기 파일 클립보드 다운로드 기록을 내보낼까요? 다운로드 기록에서 %1$s를 내보내는 중입니다. 다운로드된 파일들과 설정은 백업되지 않습니다. 다운로드 기록 다음 언어들이 미래의 다운로드들을 위한 설정에 추가됩니다: 자막 언어를 업데이트할까요? 다운로드 기록에 %1$s를 불러옴 재다운로드 비디오 컨테이너 리묵스 더 나은 호환성을 위해 비디오를 MKV 컨테이너로 리묵스하기 매주 매월 매일 %2$d개의 웹사이트의 총 %1$d개의 쿠키 저장 중 모든 언어 재생목록 계속하기 형식 설정을 사용하여 자동으로 다운로드합니다 프리셋 편집 가능한 최고 품질을 다운로드 대기열에 추가됨 영상 %d개 오디오 %d개 프리셋 형식과 자막 등을 설정합니다 %1$s 선호 여기서 다운로드를 확인할 수 있습니다 다운로드 버튼을 누르거나 영상 링크를 이 앱으로 공유하여 다운로드를 시작해 보세요 다운로드 완료 전체 링크 %1$d개에서 선택 다운로드 대기열 내비게이션 드로어 표시 재개 삭제 미디어 정보 ================================================ FILE: app/src/main/res/values-lt/strings.xml ================================================ Atsisiuntimus rasite čia Atsisiųsta Tikrinti, ar yra naujinimų Tinklas Pasirinkti viską %1$d pasirinkta Vaizdo įrašas (nėra garso) Privatumas Siūloma Stabili Neribota sekundė minutė Beta versija Įjungti eksperimentinę funkciją? Pageidauti MP4 (H.264) formatus, kad būtų galima bendrinti su kitomis programėlėmis Kas mėnesį Visos kalbos Grojaraštis Tęsti Palieskite atsisiuntimo mygtuką arba bendrinkite vaizdo įrašo nuorodą į šią programą, kad pradėtumėte atsisiuntimą Viskas Peržiūra Valyti Grojaraščio pavadinimas Suprantau Sistemos Kūrėjai ir laisvoji programinė įranga Vaizdo įrašų aplankas Išsaugoti kaip garso įrašą Išsaugoti miniatiūrą Nustatymai Bendrieji, formatas, pasirinktinė komanda Atsisiųsti Nuoroda negali būti tuščia. Atsisiųsti ir išsaugoti garso įrašą vietoj vaizdo įrašą Išsaugoti vaizdo įrašo miniatiūrą kaip failą Naudojama naujausia „yt-dlp“ versija Nepavyko įdiegti naujausios „yt-dlp“ versijos. Įsitikinkite, kad esate prisijungę prie interneto. Gaunama vaizdo įrašo informacija… Leidimas atmestas Baigtas atsisiuntimas Nepavyko atsisiųsti failo. Bendrieji Rodymo kalba Nustatyti rodymo kalbą Tamsi tema Įjungta Išjungta Atšaukti Atgal Versija Atsisiuntimai Įklijuoti URL „Yt-dlp“ versija Apie Versija, atsiliepimai, automatinis atnaujinimas Dabartinė laida Kūrėjai Pasirinktinė komanda Rodinys Tamsi tema, dinaminės spalvos, kalbos Redaguoti Parinktys Papildomi nustatymai Numatytasis Padėkite išversti šią programėlę svetainėje „Hosted Weblate“ %d elementas %d elementai %d elementų Vykdomas atsisiuntimas… Susidūrėte su klaida? Prieš pranešdami apie naują problemą, paieškokite mūsų problemų sekiklyje. Daugelis dažniausiai pasitaikančių problemų jau buvo išspręstos ir aprašytos. %d garso įrašas %d garso įrašai %d garso įrašų %d vaizdo įrašas %d vaizdo įrašai %d vaizdo įrašų ================================================ FILE: app/src/main/res/values-lv/strings.xml ================================================ Saglabāt kā audio saglabāt sīktēlu Iestatījumi Video mape ================================================ FILE: app/src/main/res/values-ml/strings.xml ================================================ വീഡിയോ ഫോൾഡർ ലഘുചിത്രം സംരക്ഷിക്കുക ക്രമീകരണങ്ങൾ ഡൗൺലോഡ് വീഡിയോ ലഘുചിത്രം ഒരു ഫയലായി സംരക്ഷിക്കുക അനുമതി നിഷേധിച്ചു റദ്ദാക്കുക Yt-dlp പതിപ്പ് ഓഡിയോ ഫയൽ ഇല്ലാതാക്കുക പതിപ്പ്, ഫീഡ്ബാക്ക്, യാന്ത്രിക അപ്ഡേറ്റ് തിരികെ പൊതുവായ, ഫോർമാറ്റ്, ഇഷ്‌ടാനുസൃത കമാൻഡ് ഓഡിയോ ആയി സംരക്ഷിക്കുക വീഡിയോയ്ക്ക് പകരം ഓഡിയോ ഡൗൺലോഡ് ചെയ്ത് സംരക്ഷിക്കുക yt-dlp-യുടെ ഏറ്റവും പുതിയ പതിപ്പ് ഉപയോഗിക്കുന്നു \"%1$s\" ഡൗൺലോഡ് ചെയ്യുക വീഡിയോ വിവരം ലഭ്യമാക്കാനായില്ല ഏറ്റവും പുതിയ yt-dlp പതിപ്പ് ഇൻസ്റ്റാൾ ചെയ്യാൻ കഴിഞ്ഞില്ല. നിങ്ങൾ ഇന്റർനെറ്റുമായി ബന്ധിപ്പിച്ചിട്ടുണ്ടെന്ന് ദയവായി ഉറപ്പാക്കുക. ഫയൽ ഡൗൺലോഡ് ചെയ്യാനായില്ല വീഡിയോ വിവരം ലഭ്യമാക്കുന്നു… നിങ്ങളുടെ ഡൗൺലോഡ് ചരിത്രത്തിൽ നിന്ന് \"%1$s\" നീക്കം ചെയ്യണോ\? ഡൗൺലോഡ് പൂർത്തിയായി പൊതുവായ പ്രദർശന ഭാഷ നിലവിലുള്ള ഒരു ഡൗൺലോഡ് ടാസ്‌ക് ഇതിനകം പ്രവർത്തിക്കുന്നു URL ഒട്ടിക്കുക പ്രദർശന ഭാഷ സജ്ജമാക്കുക ക്ലിപ്പ്ബോർഡിലെ URL-മായി പൊരുത്തപ്പെടാൻ കഴിഞ്ഞില്ല പതിപ്പ് ഏറ്റവും പുതിയ yt-dlp പതിപ്പ് ഇൻസ്റ്റാൾ ചെയ്യാൻ ക്ലിക്ക് ചെയ്യുക നീക്കം ചെയ്യണോ\? സ്ഥിരീകരിക്കുക ലിങ്ക് ക്ലിപ്പ്ബോർഡിലേക്ക് പകർത്തി ഡൗൺലോഡുകൾ ലിങ്ക് തുറക്കുക ഇതേക്കുറിച്ച് നീക്കം ചെയ്യുക ചേഞ്ച് ലോഗുകളും പുതിയ പതിപ്പുകളും തിരയുക ഗിറ്റ്ഹബ് റെപ്പോസിറ്ററിയും README-യും പരിശോധിക്കുക ഏറ്റവും പുതിയ റിലീസ് ലിങ്ക് ശൂന്യമാക്കാൻ കഴിയില്ല വീഡിയോ പരിശോധിച്ചു കസ്റ്റം കമാൻഡ് അഡ്വാൻസ്ഡ് വിശദമായ ഔട്ട്പുട്ട് Yt-dlp ഉപയോഗ റഫറൻസുകൾ ഓഡിയോ ഫോർമാറ്റ് പരിവർത്തനം ചെയ്യുക %1$s ലേക്ക് പരിവർത്തനം ചെയ്യുക വീഡിയോ ഗുണനിലവാരം വീഡിയോ ഫോർമാറ്റ് പരിവർത്തനം ചെയ്യുക അടയ്ക്കുക വീണ്ടും കാണിക്കരുത് സഹജമായ ഡൌൺലോഡ് ചെയ്ത ഫയലുകളും പുരോഗതിയും അറിയിക്കുക വീഡിയോ ലിങ്ക് മൾട്ടി-ത്രെഡ് ഡൗൺലോഡ് ഓപ്ഷനുകൾ അധിക ക്രമീകരണങ്ങൾ പങ്കിട്ട ഉള്ളടക്കത്തിൽ നിന്നുള്ള URL-മായി പൊരുത്തപ്പെടുന്നില്ല ഡൗൺലോഡ് ചെയ്ത ഫയലുകളും പുരോഗതിയും അറിയിക്കുക പ്ലേലിസ്റ്റ് തിരഞ്ഞെടുക്കൽ റദ്ദാക്കുക ക്രെഡിറ്റുകൾ കമാൻഡ് നടപ്പിലാക്കാൻ തുടങ്ങുക പിശക് റിപ്പോർട്ട് ക്ലിപ്പ്ബോർഡിലേക്ക് പകർത്തി ക്രെഡിറ്റുകളും ലിബ്രെ സോഫ്റ്റ് വെയറുകളും ഇഷ്‌ടാനുസൃത ടെംപ്ലേറ്റ് ഉപയോഗിച്ച് yt-dlp കമാൻഡ് പ്രവർത്തിപ്പിക്കുക എഡിറ്റ് ചെയ്യുക കമാൻഡ് ടെംപ്ലേറ്റ് ഓഫ് ചെയ്യുക ഓൺ ഡൌൺലോഡ് ചെയ്യുമ്പോൾ വിശദമായ സന്ദേശങ്ങൾ പ്രിന്റ് ചെയ്യുക പ്രദർശിപ്പിക്കുക ഇരുണ്ട തീം, ഡൈനാമിക് നിറം, ഭാഷകൾ ഇരുണ്ട തീം സിസ്റ്റം ഡൌൺലോഡ് ചെയ്യുന്നതിന് മുമ്പ് മുൻഗണനകൾ ക്രമീകരിക്കുക ഡൗൺലോഡ് ചെയ്യുന്നതിന് മുമ്പ് ക്രമീകരിയ്ക്കുക ലഘുചിത്രം ഈ ഡൗൺലോഡ് ക്രമീകരിക്കുക പരിവർത്തനം ചെയ്യപ്പെടാത്തത് ഫോർമാറ്റ് പേസ്റ്റ് ചെയ്യുക ഔട്ട്പുട്ട് പാത്തും URL-ഉം ആപ്ലിക്കേഷൻ ചേർക്കും. ഓഡിയോ ഫയലുകൾ വീണ്ടും എൻകോഡ് ചെയ്യുന്നത് ഓഡിയോ നിലവാരം നഷ്‌ടപ്പെടുത്താനും ഫയലിന്റെ വലുപ്പം വർദ്ധിപ്പിക്കാനും ഇടയാക്കും. മികച്ച നിലവാരം അതിന്റെ ക്രമീകരണങ്ങൾ ക്രമീകരിച്ചതിന് ശേഷം \"ഡൗൺലോഡ്\" ക്ലിക്ക് ചെയ്യുക. വ്യക്തമാക്കിയിട്ടില്ല (സ്വതവേയുള്ളത്) ഡൗൺലോഡ് ഡൗൺലോഡ് ക്രമീകരണങ്ങൾ പരിശോധിച്ച് അത് ഉപയോഗിക്കുന്നതിന് മുമ്പ് yt-dlp-യുടെ ഏറ്റവും പുതിയ പതിപ്പ് നിങ്ങളുടെ പക്കലുണ്ടെന്ന് ഉറപ്പാക്കുക. പ്ലേലിസ്റ്റ് ഡൌൺലോഡ് ചെയ്യുക ഒന്നിലധികം ഉള്ളപ്പോൾ വീഡിയോ നിലവാരം പരിമിതപ്പെടുത്തുക തിരഞ്ഞെടുത്ത വീഡിയോ ഫോർമാറ്റ് ഒന്നിലധികം നൽകുമ്പോൾ തിരഞ്ഞെടുത്ത ഫോർമാറ്റ് ഉപയോക്തൃ ഗൈഡ് ക്രമീകരണങ്ങൾ തുറക്കുക നിങ്ങളുടെ ക്ലിപ്പ്ബോർഡിൽ നിന്ന് വീഡിയോ ലിങ്ക് ലഭിക്കാൻ \"ഒട്ടിക്കുക\" ക്ലിക്ക് ചെയ്യുക. ഡൗൺലോഡ് വീഡിയോകളും ഓഡിയോ ഫയലുകളും ഉൾപ്പെടെ, ഇൻ-ആപ്പ് ഡൗൺലോഡുകൾ പരിശോധിക്കുകയും നിയന്ത്രിക്കുകയും ചെയ്യുക. ഒരു പ്ലേലിസ്റ്റിൽ നിന്ന് ഒന്നിലധികം വീഡിയോകൾ ഡൗൺലോഡ് ചെയ്യുക ഇഷ്ടാനുസൃത കമാൻഡുകൾ പ്രവർത്തിപ്പിക്കുന്നു… ഡൗൺലോഡ് പൂർത്തിയായി. തുറക്കാൻ ക്ലിക്ക് ചെയ്യുക. M3U8/MPD വീഡിയോകളുടെ കൂടുതൽ ഭാഗങ്ങൾ സമാന്തരമായി ഡൗൺലോഡ് ചെയ്യുക DASH/HLS നേറ്റീവ് വീഡിയോ ഒരേസമയം ഡൗൺലോഡ് ചെയ്യാൻ %d ത്രെഡ്(കൾ) ഉപയോഗിക്കും. കൂടുതൽ പ്രവർത്തികൾ കാണിക്കുക ഡൗൺലോഡ് അറിയിപ്പ് പങ്കിട്ട ഉള്ളടക്കത്തിൽ നിന്നുള്ള വീഡിയോ ലിങ്ക് വായിക്കുന്നു… പ്ലേലിസ്റ്റ് വിവരം ലഭ്യമാക്കുന്നു… പശ്ചാത്തലത്തിൽ ഡൗൺലോഡ് ചെയ്യുന്നതിനായി ഈ ആപ്പിന്റെ ബാറ്ററി ഉപയോഗം സിസ്റ്റം ക്രമീകരണങ്ങളിൽ \"അനിയന്ത്രിതമായത്\" എന്ന് സജ്ജീകരിക്കുക. പ്ലേലിസ്റ്റിൽ നിന്ന് ഡൗൺലോഡ് ചെയ്യുന്നതിനുള്ള വീഡിയോകളുടെ ശ്രേണി വ്യക്തമാക്കുക \"%3$s\" (%1$d മുതൽ %2$d) വരെ). ആരംഭിക്കുക അവസാനിക്കുന്നു അസാധുവായ സൂചിക ശ്രേണി ശബ്ദ ഫോൾഡർ വീഡിയോകളും ഓഡിയോ ഫയലുകളും എവിടെ സൂക്ഷിക്കണമെന്ന് തിരഞ്ഞെടുക്കുക ഉപഡയറക്‌ടറിയിൽ സംരക്ഷിക്കുക സംഭരണ അനുമതി പ്രശ്നം ഡൗൺലോഡ്/, ഡോക്യുമെന്റുകൾ/ എന്നിവയ്ക്ക് പുറത്തുള്ള ഡയറക്ടറികൾ പിന്തുണയ്ക്കുന്നില്ല ബാറ്ററി കോൺഫിഗറേഷൻ സീൽ ഡൗൺലോഡ് ചെയ്യുന്നു… അപരിചിതമായ പിശക് സബ്ടൈറ്റിലുകൾ ഉൾച്ചേർക്കുക ലേബൽ ടെംപ്ലേറ്റ് തിരഞ്ഞെടുക്കൽ കമാൻഡ് ടെംപ്ലേറ്റുകൾ എഡിറ്റ് ചെയ്യുകയും നിയന്ത്രിക്കുകയും ചെയ്യുക ഡൗൺലോഡ് ടാസ്‌ക് റദ്ദാക്കി ഗിറ്റ്ഹബ് പ്രശ്നം ലിങ്ക് ക്ലിപ്പ്ബോർഡിലേക്ക് പകർത്തി ഡൌൺലോഡ് ചെയ്യുന്നു റദ്ദാക്കി ഫയൽ തുറക്കുക പിശക് റിപ്പോർട്ട് പകർത്തുക ക്ലിപ്ബോർഡിൽ നിന്ന് ഇംപോർട്ട് ചെയ്യുക %1$d ജോലികൾ ഡൗൺലോഡ് ചെയ്യുക അടുത്തിടെ ചേർത്തത് നിങ്ങളുടെ ഡൗൺലോഡ് ചരിത്രത്തിൽ നിന്ന് %1$d ഇനം(കൾ) നീക്കം ചെയ്യണോ\? സ്പോൺസർബ്ലോക്ക് വിഭാഗങ്ങൾ അപ് ഡേറ്റുകൾ ഉണ്ടോ എന്ന് പരിശോധിക്കുക നിലവിലെ പതിപ്പ് അപ് ടു ഡേറ്റ് ആണ് ഏറ്റവും പുതിയ പതിപ്പിലേക്ക് അപ്ഡേറ്റ് ചെയ്യുന്നതിൽ പരാജയപ്പെട്ടു അപ്ഡേറ്റ് ചെയ്യുക താൽക്കാലിക ഫയലുകൾ മായ്ച്ചുകളയുക സ്വകാര്യ ഡൈനാമിക് നിറം സെല്ലുലാർ ഉപയോഗിച്ച് ഡൗൺലോഡ് ചെയ്യുക ഈ ഫയൽ ഇനി ലഭ്യമല്ല നെറ്റ് വർക്ക് നിരക്ക് പരിധി പരമാവധി ഡൗൺലോഡ് വേഗത പരിമിതപ്പെടുത്തുക പരമാവധി നിരക്ക് ഏറ്റവും കുറഞ്ഞ നിലവാരം വാൾപേപ്പറുകളിൽ നിന്ന് ആപ്പ് തീമിലേക്ക് നിറങ്ങൾ പ്രയോഗിക്കുക പശ്ചാത്തലത്തിൽ ഡൗൺലോഡ് ചെയ്യാൻ ഈ ആപ്പിനായി ബാറ്ററി ഒപ്റ്റിമൈസേഷൻ അവഗണിക്കുക കമാൻഡ് ടെംപ്ലേറ്റുകളിൽ നിന്ന് \"%1$s\" നീക്കം ചെയ്യണോ\? വീഡിയോ ഫയലിന്റെ വലിപ്പം വിവർത്തനം ചെയ്യുക പുതിയ ടെംപ്ലേറ്റ് നീക്കം ചെയ്യണോ\? വിവരങ്ങൾ ശേഖരിക്കുന്നു ഹോസ്റ്റുചെയ്ത വെബ്ലേറ്റിൽ ഈ ആപ്പ് പരിഭാഷപ്പെടുത്താൻ സഹായിക്കുക ബഗ് റിപ്പോർട്ടിനോ ഫീച്ചർ അഭ്യർത്ഥനയ്‌ക്കോ ഒരു പ്രശ്നം സമർപ്പിക്കുക പ്ലേലിസ്റ്റ് ഡൗൺലോഡ് ചെയ്യുന്നു (%1$d/%2$d)… ഡയറക്ടറി ഡൗൺലോഡ് ചെയ്യുക ഫയലുകൾ ബന്ധപ്പെട്ട ഫീൽഡുകളായി പേരുള്ള ഫോൾഡറുകളിൽ സംരക്ഷിക്കുക പാത്ത് ടെംപ്ലേറ്റ് ഡൗൺലോഡ് പുരോഗമിക്കുന്നു… ലഭ്യമെങ്കിൽ വീഡിയോകളിൽ നൽകിയിരിക്കുന്ന സബ്‌ടൈറ്റിലുകൾ ഉൾച്ചേർക്കുക വരിവരിയായി തീർന്നു പുനരാരംഭിക്കുക ലിങ്ക് പകർത്തുക ക്ലിപ്പ്ബോർഡിലേക്ക് കയറ്റുമതി ചെയ്യുക കയറ്റുമതി ചെയ്‌ത %1$d ടെംപ്ലേറ്റ്(കൾ) വീഡിയോ റെസല്യൂഷൻ കയറ്റുമതി ചെയ്‌ത %1$d ടെംപ്ലേറ്റ്(കൾ) %1$d വീഡിയോ(കൾ), %2$d ഓഡിയോ ഫയൽ(കൾ) SponsorBlock API ഉപയോഗിച്ച് വീഡിയോകളിൽ നിന്ന് സെഗ്‌മെന്റുകൾ നീക്കം ചെയ്യുക ബാഹ്യ ഡൗൺലോഡറായി aria2c ഉപയോഗിക്കുക മൾട്ടിസെലക്ട് മോഡ് ഡൌൺലോഡ് ഡയറക്ടറിയിൽ നിന്ന് എല്ലാ താൽക്കാലിക ഫയലുകളും ഇല്ലാതാക്കുക ഡൗൺലോഡ് ചരിത്രം പ്രവർത്തനരഹിതമാക്കുക മീറ്റർ നെറ്റ്‌വർക്കുകളിലേക്ക് കണക്‌റ്റ് ചെയ്യുമ്പോൾ മീഡിയ ഡൗൺലോഡ് ചെയ്യാൻ അനുവദിക്കുക വീഡിയോ ഫയലിൽ നിന്ന് നീക്കം ചെയ്യേണ്ട SponsorBlock വിഭാഗങ്ങൾ വ്യക്തമാക്കുക GitHub-ലെ ഏറ്റവും പുതിയ പതിപ്പിനായി തിരയുക ഡൗൺലോഡുകൾക്കായി Netscape ഫോർമാറ്റ് ചെയ്ത കുക്കികൾ ഉപയോഗിക്കുക ഇല്ലാതാക്കി %1$d താൽക്കാലിക ഫയൽ(കൾ) റദ്ദാക്കിയ ഡൗൺലോഡുകൾ പുനരാരംഭിക്കാൻ താൽക്കാലിക ഫയലുകൾ ഉപയോഗിക്കാം. ഈ ഫയലുകളെല്ലാം ഇല്ലാതാക്കുമെന്ന് തീർച്ചയാണോ? \n \nനിങ്ങൾക്ക് ഈ ഫയലുകൾ %1$s-ൽ ഉപയോഗിക്കാം സെല്ലുലാർ നെറ്റ്‌വർക്ക് ഉപയോഗിച്ച് ഡൗൺലോഡ് ചെയ്യുന്നത് നിങ്ങളുടെ ക്രമീകരണം അനുസരിച്ച് പ്രവർത്തനരഹിതമാക്കി ഹൈ കോൺട്രാസ്റ്റ് ഡാർക്ക് തീം അസാധുവായ ഇൻപുട്ട് സ്വകാര്യ ഡയറക്ടറി കുക്കികൾ എല്ലാം തിരഞ്ഞെടുക്കുക Yt-dlp പതിപ്പ്, അറിയിപ്പ്, പ്ലേലിസ്റ്റ് നിരക്ക് പരിധി, ഡൗൺലോഡർ, കുക്കികൾ ക്രോപ്പ് ആർട്ട് വർക്ക് സ്വകാര്യത പ്രിവ്യൂ പ്രവർത്തനരഹിതമാക്കുക ഡൗൺലോഡ് സമയത്ത് ലഘുചിത്രങ്ങൾ പ്രദർശിപ്പിക്കുന്നത് ഒഴിവാക്കുക ഇഷ്ടാനുസൃത കമാൻഡ് ഉപയോഗിക്കുക ഒരു മറഞ്ഞിരിക്കുന്ന ഡയറക്ടറിയിൽ ഡൗൺലോഡുകൾ സംഭരിക്കുക പ്ലേലിസ്റ്റിൽ നിന്ന് ഡൗൺലോഡ് ചെയ്യാൻ വീഡിയോകൾ തിരഞ്ഞെടുക്കുക \"%1$s\" ഉൾച്ചേർത്ത ചിത്രം ചതുരത്തിലേക്ക് ക്രോപ്പ് ചെയ്യുക ലഭ്യമല്ല ഫയൽ ഫോർമാറ്റ്, വീഡിയോ നിലവാരം, സബ്ടൈറ്റിലുകൾ %1$d തിരഞ്ഞെടുത്തു വീഡിയോ (ഓഡിയോ ഇല്ല) ശുപാർശ ചെയ്ത ഫോർമാറ്റ് തിരഞ്ഞെടുക്കൽ ഡൗൺലോഡ് ആരംഭിക്കുന്നതിന് മുമ്പ് ഡൗൺലോഡ് ചെയ്യേണ്ട ഫോർമാറ്റ് തിരഞ്ഞെടുക്കുക സ്പോൺസർബ്ലോക്ക് സെഗ്‌മെന്റുകൾ നീക്കം ചെയ്യുമ്പോൾ സബ്‌ടൈറ്റിലുകൾ തെറ്റായി വന്നേക്കാം. കുക്കികൾ ഉപയോഗിക്കുക \"%1$s\" എന്നതിനായുള്ള ഈ എൻട്രി നീക്കം ചെയ്യണോ? ഈ സൈറ്റിനായി സംഭരിച്ചിരിക്കുന്ന കുക്കികൾ മായ്‌ക്കില്ല എന്നത് ശ്രദ്ധിക്കുക. അതെങ്ങനെയാണ് പ്രവര്ത്തിക്കുന്നത്\? മിക്ക വീഡിയോ സ്ട്രീമിംഗ് പ്ലാറ്റ്‌ഫോമുകളും ഓഡിയോയും വീഡിയോയും വെവ്വേറെ ഡെലിവർ ചെയ്യുന്നു, നിങ്ങൾക്ക് ഒരു ഓഡിയോ മാത്രമുള്ള ഫോർമാറ്റ് തിരഞ്ഞെടുത്ത് ഒരു വീഡിയോ മാത്രമുള്ള ഫോർമാറ്റിൽ ഒരു വീഡിയോയിലേക്ക് ലയിപ്പിക്കാം. കസ്റ്റം കമാൻഡ് ഉപയോഗിക്കുമ്പോൾ ചില ഓപ്ഷനുകൾ ലഭ്യമല്ല ചില സൈറ്റുകളിൽ നിന്ന് ഡൗൺലോഡ് ചെയ്യുന്നതിന് അക്കൗണ്ട് പ്രാമാണീകരണ വിവരങ്ങൾ ആവശ്യമാണ്. \"പുതിയ കുക്കികൾ സൃഷ്ടിക്കുക\" എന്നതിൽ ക്ലിക്ക് ചെയ്യുക, വെബ്‌സൈറ്റിന്റെ URL നൽകുക, തുടർന്ന് ബ്രൗസർ പേജിൽ നിങ്ങളുടെ അക്കൗണ്ട് ഉപയോഗിച്ച് ലോഗിൻ ചെയ്യുക, ആപ്പ് നിങ്ങൾക്കായി അത് ജനറേറ്റ് ചെയ്യും. ടെലിഗ്രാം ചാനൽ മാട്രിക്സ് സ്പേസ് സബ്ടൈറ്റിലുകൾ ഡൗൺലോഡ് ചെയ്യുക വീഡിയോ ശീർഷകം മാതൃകാ വാചകം വീഡിയോ ക്രിയേറ്റർ മാതൃകാ വാചകം കുറുക്കുവഴികൾ എഡിറ്റ് ചെയ്യുക ഭാഷകൾ, ഉൾച്ചേർത്ത ഉപശീർഷകങ്ങൾ, സ്വയമേവയുള്ള അടിക്കുറിപ്പുകൾ ലോഗ് പകർത്തുക മായ്‌ക്കുക പ്രവർത്തിക്കുന്ന ജോലികൾ വിവരണപതിക കാണിക്കുക വിവരണപതിക പുതിയ കുക്കികൾ സൃഷ്ടിക്കുക എസ്.ഡി കാർഡ് ഫോൾഡർ സ്വയമേവയുള്ള അടിക്കുറിപ്പുകൾ സ്വയമേവ സൃഷ്‌ടിച്ച അടിക്കുറിപ്പുകൾ ഡൗൺലോഡ് ചെയ്യുക പെട്ടെന്ന് ഡൗൺലോഡ് ചെയ്യുക ഉപശീർഷകം ഉപശീർഷക ഭാഷകൾ ചേർക്കുക കുറുക്കുവഴികൾ കമാൻഡ് ടെംപ്ലേറ്റുകൾ രചിക്കാൻ ഉപയോഗിക്കാവുന്ന ഇഷ്‌ടാനുസൃത കുറുക്കുവഴികൾ എഡിറ്റ് ചെയ്യുക. സോഫ്റ്റ് സബ്‌ടൈറ്റിലുകൾ ഉൾച്ചേർക്കാൻ, വീഡിയോകൾ mkv കണ്ടെയ്‌നറിലേക്ക് റീമക്‌സ് ചെയ്യും. സോഫ്റ്റ് സബ്‌ടൈറ്റിലുകളുള്ള വീഡിയോകൾ കാണാൻ നിങ്ങൾക്ക് VLC മീഡിയ പ്ലെയറോ മറ്റ് അനുയോജ്യമായ ആപ്പുകളോ ഉപയോഗിക്കാം. yt-dlp അപ്ഡേറ്റ് ചെയ്യുക സെക്കന്റ് തലക്കെട്ട് ലോഡ് ചെയ്യുക പേരുമാറ്റുക മിനിറ്റ് ഡൗൺലോഡ് ചെയ്ത മീഡിയ ഇല്ല ബീറ്റ കമാൻഡ് ടെംപ്ലേറ്റുകളിൽ നിന്ന് %1$s നീക്കം ചെയ്യണോ? ഫയൽ എഡിറ്റ് ചെയ്യുക രക്ഷിക്കും %d ഇനം %d ഇനങ്ങൾ മെറ്റാഡാറ്റ ഉൾച്ചേർക്കുക മുൻഗണന നൽകേണ്ട ഓഡിയോ ഫോർമാറ്റ് പ്രോക്സി ഇഷ്‌ടാനുസൃത കമാൻഡ് ഡയറക്‌ടറി പ്രവർത്തനരഹിതമാക്കി ഒരിക്കൽ അനുവദിക്കുക ഇതിലേക്ക് കയറ്റുമതി ചെയ്യുക ഡൗൺലോഡ് ചരിത്രം ഇമ്പോർട്ടുചെയ്യണോ? ഡൗൺലോഡ് ചരിത്രം കയറ്റുമതി ചെയ്യണോ? ഡൗൺലോഡ് ചെയ്ത ഫയലുകൾ ഇറക്കുമതി ചെയ്യില്ല. നിങ്ങൾ അവ സ്വമേധയാ തിരികെ ഡൗൺലോഡ് ചെയ്യേണ്ടതുണ്ട് ഡൗൺലോഡ് ചരിത്രത്തിൽ നിന്ന് %1$s കയറ്റുമതി ചെയ്യുന്നു. ഡൗൺലോഡ് ചെയ്‌ത ഫയലുകളും മുൻഗണനകളും ബാക്കപ്പ് ചെയ്യില്ല. ആകെ %2$d വെബ്‌സൈറ്റുകളിൽ നിന്ന് %1$d കുക്കികൾ എല്ലാ ദിവസവും എല്ലാ ആഴ്ചയും യാന്ത്രികമായി വിവർത്തനം ചെയ്‌ത സബ്‌ടൈറ്റിലുകൾ യാന്ത്രികമായി വിവർത്തനം ചെയ്‌ത സബ്‌ടൈറ്റിലുകൾ എല്ലാ ഭാഷകൾക്കും ഡൗൺലോഡുകളിൽ ലഭ്യമാകും. ഈ സബ്‌ടൈറ്റിലുകൾ കൃത്യമല്ലാത്തതും മനസ്സിലാക്കാൻ ബുദ്ധിമുട്ടുള്ളതുമാകാം. yt-dlp വീഡിയോകൾ ഡൗൺലോഡ് ചെയ്യുന്നതിനുള്ള ശക്തമായ ഒരു കമാൻഡ്-ലൈൻ ഉപകരണമാണ്. ഒരു അവബോധജന്യമായ GUI, പൊതുവായ കമാൻഡുകൾക്കുള്ള പ്രീസെറ്റുകൾ, മറ്റ് അധിക സവിശേഷതകൾ എന്നിവ നൽകിക്കൊണ്ട് yt-dlp ഉപയോഗിക്കുന്നത് സീൽ എളുപ്പമാക്കുന്നു. \n \nyt-dlp-യുടെ വിപുലമായ ഉപയോഗത്തിനായി, ഒരു ടെർമിനലിൽ പോലെ നേരിട്ട് ഇഷ്‌ടാനുസൃത കമാൻഡ് ടെംപ്ലേറ്റുകൾ സൃഷ്‌ടിക്കാനും സംരക്ഷിക്കാനും നടപ്പിലാക്കാനും സീൽ നിങ്ങളെ അനുവദിക്കുന്നു. \n \nഇഷ്‌ടാനുസൃത കമാൻഡുകൾ ഉപയോഗിക്കുമ്പോൾ, മിക്ക GUI ഓപ്ഷനുകളും സവിശേഷതകളും പ്രവർത്തനരഹിതമാകും. പുതിയ ഫീച്ചറുകളും മാറ്റങ്ങളും പ്രിവ്യൂ ചെയ്യുന്നതിന് പ്രീ-റിലീസ് ബിൽഡുകൾ ഇൻസ്റ്റാൾ ചെയ്യുക. \n \nഈ പതിപ്പുകളിൽ ചില അസ്ഥിരതകൾ ഉണ്ടാകും, അതിനാൽ ഭാവിയിൽ ആപ്പ് മെച്ചപ്പെടുത്താൻ ഞങ്ങളെ സഹായിക്കുന്നതിന് എന്തെങ്കിലും പ്രശ്‌നങ്ങൾ നേരിടുകയാണെങ്കിൽ ഞങ്ങൾക്ക് ഫീഡ്‌ബാക്ക് നൽകാൻ മടിക്കരുത്. സ്പോൺസർ GitHub-ൽ സ്പോൺസർ ചെയ്തുകൊണ്ട് ഈ ആപ്പിനെ പിന്തുണയ്ക്കുക %1$s ബിൽഡുകൾക്ക് സ്വയമേവയുള്ള അപ്ഡേറ്റ് ലഭ്യമല്ല. നിങ്ങളുടെ ഉപകരണത്തിൽ %1$s ഇൻസ്റ്റാൾ ചെയ്തിട്ടില്ലെങ്കിലോ സീലിൽ വരാനിരിക്കുന്ന പുതിയ ഫീച്ചറുകൾ പ്രിവ്യൂ ചെയ്യാൻ ആഗ്രഹിക്കുന്നുവെങ്കിലോ, ദയവായി %2$s പരിഗണിക്കുക. ഫീച്ചർ ലഭ്യമല്ല ഇഷ്‌ടാനുസൃത കമാൻഡ് ടാസ്‌ക്കുകളൊന്നുമില്ല സബ്ടൈറ്റിലുകൾ പരിവർത്തനം ചെയ്യുക സബ്ടൈറ്റിലുകൾ മറ്റൊരു ഫോർമാറ്റിലേക്ക് പരിവർത്തനം ചെയ്യുക ഇൻ്റർനെറ്റ് കണക്ഷനുകൾക്ക് പ്രോക്സി ഉപയോഗിക്കുക പഴയത് ഗുണമേന്മ അറിയിപ്പുകൾ പ്രവർത്തനക്ഷമമാക്കണോ? ഡൗൺലോഡ് നിലയെയും പുരോഗതിയെയും കുറിച്ചുള്ള അറിയിപ്പുകൾ പോസ്റ്റുചെയ്യാൻ ആപ്പിന് നിങ്ങളുടെ അനുമതി ആവശ്യമാണ്. മറ്റ് ആപ്പുകളിലേക്ക് പങ്കിടുന്നതിന് MP4(H.264) ഫോർമാറ്റുകൾ തിരഞ്ഞെടുക്കുക അനുയോജ്യമായ ആപ്പുകളിൽ കാണുന്നതിന് AV1, VP9 അല്ലെങ്കിൽ H.265 ഫോർമാറ്റുകൾ തിരഞ്ഞെടുക്കുക അജ്ഞാതം പുതിയ കുക്കികൾ സൃഷ്‌ടിക്കാൻ വെബ്‌പേജ് തുറക്കാൻ ടാപ്പ് ചെയ്യുക: പ്രീസെറ്റുകൾ ഡ്യൂപ്ലിക്കേറ്റ് ഡൗൺലോഡുകൾ ഒഴിവാക്കാൻ ഡൗൺലോഡ് ചെയ്ത വീഡിയോ ഐഡികൾ ഒരു ആർക്കൈവിൽ റെക്കോർഡ് ചെയ്യുക ആർക്കൈവ് ഫയലിൽ നിന്ന് %1$s നീക്കം ചെയ്യണോ? ഡൗൺലോഡ് ആർക്കൈവ് മായ്‌ക്കണോ? ഫോർമാറ്റ് സോർട്ടിംഗ് ഉപയോഗിക്കുക അനുയോജ്യത ഉറപ്പാക്കാൻ ഫയൽ നാമങ്ങൾ നിർദ്ദിഷ്ട പ്രതീകങ്ങളിലേക്ക് പരിമിതപ്പെടുത്തുക ഒന്നിലധികം ഓഡിയോ സ്ട്രീം ലയിപ്പിക്കുക ഒന്നിലധികം ഓഡിയോ സ്ട്രീമുകൾ ഒരൊറ്റ ഫയലിലേക്ക് ലയിപ്പിക്കാൻ അനുവദിക്കുക ഡൗൺലോഡുകളിൽ തിരയുക തിരയുക കോമകളാൽ വേർതിരിച്ച, സ്വയമേവയുള്ള ഫോർമാറ്റ് തിരഞ്ഞെടുപ്പിൽ ഡൗൺലോഡ് ചെയ്യാനുള്ള സബ്‌ടൈറ്റിലുകളുടെ ഭാഷ. അടുത്ത ഡൗൺലോഡിനായി ഓർക്കുക ഭാവിയിലെ ഡൗൺലോഡുകൾക്കായി ഇനിപ്പറയുന്ന ഭാഷകൾ നിങ്ങളുടെ മുൻഗണനയിലേക്ക് ചേർക്കും: സബ്‌ടൈറ്റിൽ ഭാഷകൾ അപ്‌ഡേറ്റ് ചെയ്യണോ? ഇറക്കുമതി ചെയ്യുക എല്ലായ്പ്പോഴും അനുവദിക്കുക അനുവദിക്കരുത് സെല്ലുലാർ ഉപയോഗിച്ച് ഡൗൺലോഡ് ചെയ്യാൻ അനുവദിക്കണോ? മനസ്സിലായി നിങ്ങളുടെ ഡൗൺലോഡുകൾ ഇങ്ങനെ സംരക്ഷിക്കപ്പെടും: പരീക്ഷണാത്മക ഫീച്ചർ പ്രവർത്തനക്ഷമമാക്കണോ? സീൽ എല്ലായ്‌പ്പോഴും സൗജന്യവും ഓപ്പൺ സോഴ്‌സും ആയിരിക്കും. നിങ്ങൾക്കത് ഇഷ്‌ടമാണെങ്കിൽ, GitHub-ൽ എന്നെ സ്‌പോൺസർ ചെയ്യുന്നത് പരിഗണിക്കൂ! പ്രതികരണം സ്പോൺസർമാർ വീഡിയോ വിഭജിക്കുക വീഡിയോ %1$d അധ്യായങ്ങളായി വിഭജിക്കും User-Agent തലക്കെട്ട് %.2f MB %.2f GB പങ്കിടുക സ്ഥിരതയുള്ള ചാനൽ അപ്ഡേറ്റ് ചെയ്യുക അപ്ഡേറ്റ് തന്നത്താനെ ചെയ്യുക യാന്ത്രിക അപ്‌ഡേറ്റ് പ്രവർത്തനക്ഷമമാക്കുക അൺലിമിറ്റഡ് ഏറ്റവും കുറഞ്ഞ ബിറ്റ്റേറ്റ് ഓഡിയോ നിലവാരം ഒന്നിലധികം ഗുണങ്ങൾ ഉള്ളപ്പോൾ ഓഡിയോ ബിറ്റ്റേറ്റ് പരിമിതപ്പെടുത്തുക ഫോർമാറ്റ് സോർട്ടിംഗ് yt-dlp-ൻ്റെ -S ഓപ്ഷൻ ഉപയോഗിച്ച് ഫോർമാറ്റുകൾ അടുക്കുന്നു എല്ലാ കുക്കികളും മായ്‌ക്കുക ആന്തരിക ഡയറക്ടറിയിൽ താൽക്കാലിക ഫയലുകൾ സംഭരിക്കുക വീഡിയോയുടെ തിരഞ്ഞെടുത്ത വിഭാഗങ്ങൾ ഡൗൺലോഡ് ചെയ്യുന്നതിനായി ഈ ഫീച്ചർ ഉപയോഗിക്കുന്ന ഡൗൺലോഡുകൾ FFmpeg-ലേക്ക് ഡെലിഗേറ്റ് ചെയ്യപ്പെടും, ഈ ഫീച്ചർ ഇപ്പോഴും പരീക്ഷണാത്മകമാണ്, കട്ടിംഗ് പൂർണ്ണമായും കൃത്യമല്ല, എല്ലാ ഫോർമാറ്റുകളും ഈ ഫീച്ചറിനെ പിന്തുണയ്ക്കുന്നില്ല, നിങ്ങൾക്ക് വേഗത കുറഞ്ഞ ഡൗൺലോഡ് വേഗത അനുഭവപ്പെടാം. URL-ൽ നിന്ന് വീഡിയോകൾ ഡൗൺലോഡ് ചെയ്യുക പകർത്തി പുറത്തുകടക്കുക വികസിപ്പിക്കുക പുതിയ ഡൗൺലോഡ് ടാസ്ക് ആരംഭിക്കുക \"%1$s\" എഡിറ്റ് ചെയ്യുക പ്രയോഗിക്കുക നിരസിക്കുക വീഡിയോ ക്ലിപ്പ് ചെയ്യുക സബ്ടൈറ്റിൽ ഫയലുകൾ സൂക്ഷിക്കുക പ്രിവ്യൂ ആപ്പിൽ സംഭരിച്ചിരിക്കുന്ന എല്ലാ കുക്കികളും ഇല്ലാതാക്കണോ? ഫോൾഡർ പിക്കർ യാന്ത്രികമായി തിരഞ്ഞെടുക്കുക ഇഷ്ടാനുസരണം കൂടുതലറിയുക Remux വീഡിയോ കണ്ടെയ്നർ മികച്ച അനുയോജ്യതയ്ക്കായി എംകെവി കണ്ടെയ്‌നറിലേക്ക് വീഡിയോകൾ റീമുക്സ് ചെയ്യുക ഓഡിയോ ഫയലിൽ മെറ്റാഡാറ്റയും വീഡിയോ ലഘുചിത്രവും ഉൾപ്പെടുത്തുക ആവശ്യമാണ് വീഡിയോ ഡൗൺലോഡ് ചെയ്തു. ഇത് പ്രതീക്ഷിച്ച സ്വഭാവമല്ലെങ്കിൽ, നിങ്ങളുടെ ഡൗൺലോഡ് ആർക്കൈവ് പരിശോധിക്കുക. എല്ലാ %1$d ഇനങ്ങളും കാണിക്കുക നിന്ന് ഇറക്കുമതി ചെയ്യുക ഫയലിലേക്ക് കയറ്റുമതി ചെയ്യുക ഫയലുകളുടെ പേരുകൾ നിയന്ത്രിക്കുക വെബ്സൈറ്റ് പ്ലേലിസ്റ്റ് ശീർഷകം ഓഡിയോ ഫോർമാറ്റ് ആരംഭിക്കുക അവസാനിക്കുന്നു ഫോർമാറ്റ് തിരഞ്ഞെടുക്കൽ പേജിൽ വീഡിയോ ക്ലിപ്പുകൾ ഉണ്ടാക്കുക GitHub ബിൽഡുകളിലേക്ക് മാറുന്നു ശരി ഡെവലപ്പറിൽ നിന്നുള്ള സന്ദേശം വളരെ നന്ദി! ശ്ശോ! എന്തോ കുഴപ്പം സംഭവിച്ചു പ്രവർത്തനരഹിതമാക്കുക ഡയറക്ടറി സജ്ജീകരിക്കാൻ ടാപ്പ് ചെയ്യുക ഇഷ്ടാനുസൃത കമാൻഡുകൾ ഉപയോഗിക്കുമ്പോൾ ഔട്ട്പുട്ട് ഡയറക്ടറി വ്യക്തമാക്കുക ഡൗൺലോഡ് തരം കമാൻഡുകൾ ഫോർമാറ്റ് മുൻഗണന ഔട്ട്പുട്ട് ടെംപ്ലേറ്റ് ഔട്ട്പുട്ട് ഫയൽ പേരുകൾക്കായുള്ള ടെംപ്ലേറ്റ് വ്യക്തമാക്കുക സിസ്റ്റം ക്രമീകരണങ്ങൾ കാണുക & അനുഭവിക്കുക ഇൻ്റർഫേസും ഇടപെടലും മുമ്പത്തെ തിരഞ്ഞെടുപ്പ് ഉപയോഗിക്കുക ഒന്നുമില്ല പുനഃസജ്ജമാക്കുക സബ്‌ടൈറ്റിലുകളിൽ തിരയുക വേണ്ട, നന്ദി കയറ്റുമതി ചെയ്യുക പൂർണ്ണ ബാക്കപ്പ് ബാക്കപ്പ് തരം ഫയൽ ക്ലിപ്പ്ബോർഡ് ഡൗൺലോഡ് ചരിത്രം ആർക്കൈവ് ഡൗൺലോഡ് ചെയ്യുക IPv4 നിർബന്ധിക്കുക IPv4 വഴി എല്ലാ കണക്ഷനുകളും ഉണ്ടാക്കുക എല്ലാ മാസവും ചരിത്രം ഡൗൺലോഡ് ചെയ്യാൻ %1$s ഇറക്കുമതി ചെയ്തു വീണ്ടും ഡൗൺലോഡ് ചെയ്യുക പ്രീസെറ്റ് എഡിറ്റ് ചെയ്യുക ക്യൂവിൽ ടാസ്ക് ചേർത്തു നിങ്ങളുടെ ഡൗൺലോഡുകൾ ഇവിടെ കാണാം ഡൗൺലോഡ് ആരംഭിക്കാൻ ഡൗൺലോഡ് ബട്ടണിൽ ടാപ്പ് ചെയ്യുക അല്ലെങ്കിൽ ഈ ആപ്പിലേക്ക് ഒരു വീഡിയോ ലിങ്ക് പങ്കിടുക ഡൗൺലോഡ് ചെയ്തു പ്ലേലിസ്റ്റ് തുടരുക പ്രീസെറ്റ് %1$s തിരഞ്ഞെടുക്കുക ഫോർമാറ്റുകൾ, സബ്‌ടൈറ്റിലുകൾ എന്നിവയിൽ നിന്ന് തിരഞ്ഞെടുത്ത് കൂടുതൽ ഇഷ്ടാനുസൃതമാക്കുക ലഭ്യമായ ഏറ്റവും മികച്ച ഫോർമാറ്റ് ഡൗൺലോഡ് ചെയ്യുക എല്ലാം %d വീഡിയോ %d വീഡിയോകൾ %d ഓഡിയോ %d ഓഡിയോകൾ %1$d ലിങ്കുകളിൽ നിന്ന് തിരഞ്ഞെടുക്കുക ഡൗൺലോഡ് ക്യൂ എല്ലാ ഭാഷകളും നിങ്ങളുടെ ഫോർമാറ്റ് മുൻഗണനകൾ ഉപയോഗിച്ച് സ്വയമേവ ഡൗൺലോഡ് ചെയ്യുക നാവിഗേഷൻ ഡ്രോയർ കാണിക്കുക പുനരാരംഭിക്കുക ഇല്ലാതാക്കുക മീഡിയയുടെ വിവരം നിങ്ങൾ ഒരു പിശക് നേരിട്ടോ? അത് അറിയിപ്പിക്കുന്നതിനു മുമ്പ് ഞങ്ങളുടെ പ്രശ്‌ന ട്രാക്കർ തിരയുക. പൊതുവായ പല പ്രശ്നങ്ങളും ഇതിനകം തന്നെ അവിടെ അഭിസംബോധന ചെയ്യുകയും രേഖപ്പെടുത്തുകയും ചെയ്തിട്ടുണ്ട്. പൊതുവായ പിശകുകൾ പരിഹരിക്കുക, അറിയപ്പെടുന്ന പ്രശ്നങ്ങൾ കാണുക പ്രശ്നനിർണയവും പരിഹാരവും പ്രശ്ന ട്രാക്കർ ================================================ FILE: app/src/main/res/values-mn/strings.xml ================================================ Видео хавтас Дараалсан Дууслаа Эхлэх Татаж авах төрөл Үргэлж зөвшөөрөх %d видео %d видео Аудио болгон хадгалах Өнгөц зургийг хадгалах Тохиргоо Ерөнхий, формат, захиалгат тушаал Татаж авах Холбоос хоосон байж болохгүй Видеоны оронд аудиог татаж аваад хадгалаарай Видеоны өнгөц зургийг файл болгон хадгалах yt-dlp-ийн хамгийн сүүлийн хувилбарыг ашиглаж байна Хамгийн сүүлийн үеийн yt-dlp хувилбарыг суулгаж чадсангүй. Та интернетэд холбогдсон эсэхээ шалгана уу. Видеоны мэдээллийг дуудаж байна… Зөвшөөрөл татгалзсан Татаж дууссан Файлыг татаж чадсангүй \"%1$s\" татаж авах Видеоны мэдээллийг татаж авч чадсангүй Генерал Дэлгэцийн хэл Дэлгэцийн хэлийг тохируулах Одоо байгаа татаж авах ажил аль хэдийн ажиллаж байна URL буулгах Түр санах ойн URL-тай таарч чадсангүй Yt-dlp хувилбар Хамгийн сүүлийн үеийн yt-dlp хувилбарыг суулгахын тулд товшино уу yt-dlp-г шинэчлэх Устгах уу? \"%1$s\"-г татаж авах түүхээсээ бүрмөсөн устгах уу? Баталгаажуулах Цуцлах Татаж авсан зүйлс Аудио Холбоосыг санах ой руу хуулсан Холбоосыг нээх Устгах Файл устгах тухай Хувилбар, санал хүсэлт, автомат шинэчлэлт Буцах Хувилбар Өөрчлөлтийн бүртгэл болон шинэ хувилбаруудыг хайж олоорой Хамгийн сүүлийн хувилбар GitHub репозитор болон README-г шалгана уу Видео Шалгасан Зээл Кредит болон үнэгүй програм хангамж Тусгай тушаал yt-dlp командыг тусгай загвараар ажиллуулна уу Тушаалын загвар Засварлах Командыг гүйцэтгэж эхэлнэ Дэвшилтэт Нарийвчилсан гаралт Татаж авахдаа дэлгэрэнгүй мессежийг хэвлэх Дэлгэц Харанхуй сэдэв, динамик өнгө, хэл Харанхуй сэдэв Систем Асаалттай Унтраах Цуцлах Татаж авахаасаа өмнө тохируулна уу Татаж авахаасаа өмнө тохиргоогоо тохируулна уу Энэ татан авалтыг тохируулна уу Алдааны тайланг санах ой руу хуулсан Өнгөц зураг Буулгах Yt-dlp хэрэглээний лавлагаа Гаралтын зам болон URL-г апп нэмнэ. Аудио форматыг хөрвүүлэх Хөрвүүлээгүй %1$s руу хөрвүүлэх Формат Аудио файлуудыг дахин кодчилсноор дууны чанар муудаж, файлын хэмжээ нэмэгдэх болно. Видеоны чанар Хамгийн сайн чанар Олон хүн байгаа үед видеоны чанарыг хязгаарлаарай Тодорхойгүй (өгөгдмөл) Сонгодог видео формат Олон өгөгдсөн тохиолдолд илүүд үздэг формат Видео формат Хөрвүүлэх Татаж авах Хаах Дахиж битгий хараарай Хэрэглэгчийн гарын авлага Тохиргоог нээх Өөрийн санах ойноос видео линкийг авахын тулд \"Буулгах\" дээр дарна уу. Дараа нь тохиргоог нь тохируулсны дараа \"Татаж авах\" дээр дарна уу. Видео болон аудио файл зэрэг апп доторх татан авалтыг шалгаж, удирдах. Татаж авах тохиргоог харж, шалгана уу Та үүнийг ашиглахаасаа өмнө yt-dlp-ийн хамгийн сүүлийн хувилбартай байна. Тоглуулах жагсаалтыг татаж авах Тоглуулах жагсаалтаас олон видео татаж авах Өгөгдмөл Татаж авах Татаж авсан файлууд болон явцын талаар мэдэгдэх Видео холбоос Татаж дууссан. Нээх бол товшино уу. Тусгай тушаалуудыг ажиллуулж байна… Ард татаж авахын тулд энэ програмын батарейны хэрэглээг системийн тохиргооноос \"Хязгааргүй\" болгож тохируулна уу. Олон урсгалтай татаж авах M3U8/MPD видеоны бусад хэсгийг зэрэгцүүлэн татаж аваарай %d урсгалыг DASH/HLS үндсэн видеог зэрэг татахад ашиглах болно. Сонголтууд Нэмэлт тохиргоо Хуваалцсан контентын URL-г тааруулах боломжгүй Хуваалцсан контентоос видео холбоосыг уншиж байна… Илүү олон үйлдлийг харуулах Мэдэгдэл татаж авах Татаж авсан файлууд болон явцын талаар мэдэгдэх Тоглуулах жагсаалтын мэдээллийг дуудаж байна… Тоглуулах жагсаалтын сонголт \"%3$s\" (%1$d-аас %2$d хүртэл) тоглуулах жагсаалтаас татаж авах видеоны хүрээг зааж өгнө үү. Эхлэх Төгсгөл Индексийн хүрээ буруу байна Тоглуулах жагсаалтыг татаж байна (%1$d/%2$d)… Аудио хавтас Татаж авах лавлах Видео болон аудио файлуудыг хаана хадгалахаа сонгоно уу Дэд директорт хадгалах Файлуудыг холбогдох талбарт нэрлэсэн хавтсанд хадгална уу Хадгалах зөвшөөрлийн асуудал Татаж авах/ болон Баримт бичиг/ гаднах лавлахуудыг дэмждэггүй Зайны тохиргоо Энэ програмыг далд татаж авахын тулд батерейны оновчлолыг үл тоомсорло Seal татаж авч байна… Үл мэдэгдэх алдаа Орчуулах Энэ програмыг Hosted Weblate дээр орчуулахад тусална уу Угтвар Хадмал орчуулга оруулах Боломжтой бол видеонуудад зөөлөн дэд файлуудыг оруулаарай Шинэ загвар Шошго Устгах уу? \"%1$s\"-г тушаалын загвараас бүрмөсөн устгах уу? Загвар сонгох Командын загваруудыг засах, удирдах Татаж авч байна… Татаж авах ажлыг цуцалсан GitHub асуудал Алдааны тайлан эсвэл функцийн хүсэлтийн талаар асуудал илгээнэ үү Мэдээллийг санах ой руу хуулсан Татаж байна Цуцлагдсан Мэдээлэл авч байна Файлыг нээх Дахин эхлүүлэх Алдаа Холбоосыг хуулах Тайлан хуулах Видеоны нягтрал Видео файлын хэмжээ Түр санах ой руу экспортлох Түр санах ойноос импорт хийх Экспортолсон %1$d загвар(ууд) Импортолсон %1$d загвар(ууд) %1$d Даалгавруудыг татаж авах Саяхан нэмэгдсэн %1$d видео(s), %2$d аудио файл(ууд) Татаж авсан түүхээсээ %1$d зүйлийг бүрмөсөн устгах уу? SponsorBlock API ашиглан видеонуудын сегментүүдийг устгах эсвэл тэмдэглэх Видео файлаас хасах эсвэл тэмдэглэхийн тулд SponsorBlock ангиллыг зааж өгнө үү SponsorBlock ангилал Шинэчлэлт байгаа эсэхийг шалгана уу GitHub дээрх хамгийн сүүлийн хувилбарыг автоматаар шалгана уу Одоогийн хувилбар нь шинэчлэгдсэн Хамгийн сүүлийн хувилбар руу шинэчилж чадсангүй Шинэчлэх aria2c-г гадаад татагч болгон ашигла Күүки Татаж авахдаа Netscape форматтай күүки ашиглана уу Түр зуурын файлуудыг арилгах Түр зуурын лавлахаас түр зуурын бүх файлыг устгана уу %1$d түр файлыг устгасан Цуцлагдсан татан авалтыг үргэлжлүүлэхэд түр зуурын файлуудыг ашиглаж болно. Та эдгээр бүх файлыг устгахдаа итгэлтэй байна уу? \n \nТа эдгээр файлд %1$s-д хандах боломжтой Олон сонголттой горим Нууцлалтай Татаж авсан түүхийг идэвхгүй болгох Динамик өнгө Апп-ын сэдэвт ханын цааснаас өнгө хэрэглээрэй Үүрэн холбоо ашиглан татаж авах Хэмжээтэй сүлжээнд холбогдсон үед медиа татаж авахыг зөвшөөрнө үү Таны тохиргооны дагуу үүрэн сүлжээгээр татаж авахыг идэвхгүй болгосон Энэ файлыг ашиглах боломжгүй болсон Сүлжээ Үнийн хязгаарлалт Татаж авах хамгийн дээд хурдыг хязгаарлах Хамгийн их хувь хэмжээ Өндөр тодосгогчтой бараан загвар Буруу оролт Хамгийн бага чанар Боломжгүй Файлын формат, видеоны чанар, хадмал орчуулга Yt-dlp хувилбар, мэдэгдэл, тоглуулах жагсаалт Үнийн хязгаарлалт, татагч, күүки Урьдчилан үзэхийг идэвхгүй болгох Татаж авах явцад өнгөц зургийг харуулахгүй Нууцлал Тусгай тушаалыг ашиглана уу Хувийн лавлах Татаж авсан файлуудыг далд санд хадгалах Урлагийн тайралт Суулгасан зургийг дөрвөлжин болгож тайрах \"%1$s\" тоглуулах жагсаалтаас татаж авах видеонуудыг сонгоно уу. Бүгдийг сонгоно уу %1$d сонгосон Видео (аудио байхгүй) Санал болгосон Формат сонгох Татаж эхлэхээсээ өмнө татаж авах форматаа сонгоно уу Шинэ күүки үүсгэх Күүки ашиглах \"%1$s\"-н энэ оруулгыг устгах уу? Энэ сайтад хадгалагдсан күүки устгагдахгүй гэдгийг анхаарна уу. Захиалгат тушаалыг ашиглах үед зарим сонголтууд боломжгүй байдаг Энэ нь хэрхэн ажилладаг вэ? Зарим сайтаас татаж авахад акаунтын баталгаажуулалтын мэдээлэл шаардлагатай. \"Шинэ күүки үүсгэх\" дээр дарж, вэб сайтын URL хаягийг оруулаад хөтчийн хуудсанд өөрийн бүртгэлээр нэвтэрч, апп үүнийг танд үүсгэх болно. yt-dlp бол видео татаж авах командын шугамын хүчирхэг хэрэгсэл юм. Seal нь ойлгомжтой GUI, нийтлэг командуудад зориулсан урьдчилан тохируулгууд болон бусад нэмэлт функцээр хангаснаар yt-dlp-г ашиглахад хялбар болгодог. \n \nYt-dlp-г илүү сайн ашиглахын тулд Seal нь танд захиалгат командын загваруудыг шууд үүсгэх, хадгалах, гүйцэтгэх боломжийг олгодог. яг терминал дээрх шиг. \n \nЗахиалгат тушаалуудыг ашиглах үед ихэнх GUI сонголтууд болон функцууд идэвхгүй болно. Telegram суваг Матрицын орон зай SD картны хавтас Автомат тайлбар Автоматаар үүсгэсэн тайлбарыг татаж авах Түргэн татаж авах Ихэнх видео стриминг платформууд аудио болон видеог тусад нь хүргэдэг тул та зөвхөн видео форматтай зөвхөн аудио форматыг сонгож, нэг видео болгон нэгтгэж болно. Видеоны гарчгийн жишээ текст Видео бүтээгчийн жишээ текст Хадмал орчуулга Хадмал орчуулгыг татаж авах Хадмал орчуулгын хэлүүд Хэл, хадмал оруулах, автомат тайлбар Бүртгэлийг хуулах Тодорхой Товчлолыг засах Нэмэх Товчлолууд Командын загвар зохиоход ашиглаж болох захиалгат товчлолуудыг засварлана уу. Ажиллаж байгаа ажлууд Бүртгэлийг харуулах Бүртгэл SponsorBlock сегментүүдийг хасах үед хадмал орчуулгын цаг буруу байж болзошгүй. Зөөлөн хадмал оруулахын тулд видеонуудыг mkv контейнерт дахин оруулах болно. Та зөөлөн хадмалтай видео үзэхийн тулд VLC Media Player эсвэл бусад нийцтэй програмуудыг ашиглаж болно. %.2f МБ %.2f ГБ Хуваалцах Тогтвортой Урьдчилан үзэх Шинэ боломжууд болон өөрчлөлтүүдийг урьдчилан харахын тулд хувилбарын өмнөх хувилбаруудыг суулгана уу. \n \nЭдгээр хувилбаруудад тогтворгүй байдал гарах тул ирээдүйд апп-г сайжруулахад туслах ямар нэгэн асуудал гарвал бидэнд санал хүсэлтээ өгөхөөс бүү эргэлз. Сувгийг шинэчлэх Автоматаар шинэчлэх Автомат шинэчлэлтийг идэвхжүүлэх Хая Өргөдөл гаргах Клип видео Эхлэх Төгсгөл Сонгосон аудио формат Хязгааргүй Хамгийн бага бит хурд Аудио чанар Олон чанар байгаа үед аудио битийн хурдыг хязгаарлах Формат эрэмбэлэх yt-dlp-ийн -S сонголтоор форматуудыг эрэмбэлэх Импорт Гарчиг Нэрээ өөрчлөх хоёрдугаарт минут Бүх күүкийг устгана уу Апп-д хадгалагдсан бүх күүкиг бүрмөсөн устгах уу? Түр зуурын файлуудыг дотоод директорт хадгалах Ивээн тэтгэгч GitHub дээр ивээн тэтгэж энэ програмыг дэмжээрэй Seal нь үргэлж үнэ төлбөргүй, хүн бүрт нээлттэй эх сурвалж байх болно. Хэрэв танд таалагдаж байвал GitHub дээр намайг ивээн тэтгэх талаар бодож үзээрэй! Санал хүсэлт Ивээн тэтгэгчид Аудио формат Татаж авсан медиа байхгүй Бета Туршилтын онцлогийг идэвхжүүлэх үү? Энэ функцийг ашигласан татан авалтыг FFmpeg-д шилжүүлж, видеоны сонгосон хэсгүүдийг татаж авах болно, энэ функц нь туршилтын хэвээр байгаа бөгөөд огтлолт нь бүрэн нарийвчлалтай биш, бүх формат энэ функцийг дэмждэггүй бөгөөд та татаж авах хурд нь удааширч магадгүй юм. Формат сонгох хуудсанд видео клип хийх %1$s-д автоматаар шинэчлэх боломжгүй. Хэрэв таны төхөөрөмж дээр %1$s суулгаагүй эсвэл Seal-д удахгүй гарах шинэ боломжуудыг үзэхийг хүсвэл %2$s-г анхаарч үзээрэй. GitHub бүтэц рүү шилжих За Ойлголоо Онцлог боломжгүй Тусгай тушаалын даалгавар байхгүй Хөгжүүлэгчээс ирсэн мессеж Маш их баярлалаа! URL-аас видео татаж авах Хадмал орчуулгыг хөрвүүлэх Хадмал орчуулгыг өөр формат руу хөрвүүлэх Видеог хуваах Видеог %1$d бүлэгт хуваах болно Өө! Ямар нэг алдаа гарлаа Хуулах, гарах Өргөтгөх Шинэ татаж авах даалгавар \"%1$s\"-г засах Прокси Интернэт холболтын хувьд прокси ашиглана уу Өв залгамжлал Чанартай Мэдэгдлийг идэвхжүүлэх үү? Татаж авах байдал болон явцын талаар мэдэгдэл нийтлэхийн тулд апп нь таны зөвшөөрөл шаардлагатай. Идэвхгүй болгох Лавлахыг тохируулахын тулд товшино уу Тусгай тушаалын лавлах Идэвхгүй Хавтас сонгогч Захиалгат тушаалуудыг ашиглахдаа гаралтын лавлахыг зааж өгнө үү Бусад программтай хуваалцахын тулд MP4(H.264) форматыг илүүд үзээрэй Тохиромжтой программ дээр үзэхийн тулд AV1, VP9 эсвэл H.265 форматыг сонго Захиалгат Автомат Тушаалууд Форматын сонголт Илүү ихийг мэдэж аваарай Тодорхойгүй Шинэ күүки үүсгэх вэб хуудсыг нээхийн тулд товшино уу: %1$s-г тушаалын загвараас бүрмөсөн устгах уу? Хэрэглэгч-Агент толгой Файл руу экспортлох Гаралтын загвар Урьдчилсан тохируулгууд Гаралтын файлын нэрийн загварыг зааж өгнө үү Архив татаж авах Давхардсан таталтаас зайлсхийхийн тулд татаж авсан видеоны ID-г архивт тэмдэглэнэ үү Татаж авах архивыг арилгах уу? Архив файлаас %1$s-г бүрмөсөн устгах уу? Мета өгөгдлийг оруулах Аудио файлд мета өгөгдөл болон видеоны өнгөц зургийг оруулах Шаардлагатай Бүх %1$d зүйлийг харуулах Файлыг засах Хадгалах Формат эрэмбэлэх Файлын нэрийг хязгаарлах Тохиромжтой байдлыг хангахын тулд файлын нэрийг тодорхой тэмдэгтээр хязгаарлаарай Вэб сайт Тоглуулах жагсаалтын гарчиг Таны татсан зүйлс дараах байдлаар хадгалагдах болно: Системийн тохиргоо IPv4-г хүчлэх Бүх холболтыг IPv4-ээр хийнэ үү Хадмал файлуудыг хадгалах Нэг удаа зөвшөөрөх Бүү зөвшөөр Үүрэн утсаар татаж авахыг зөвшөөрөх үү? Олон аудио урсгалыг нэгтгэх Олон аудио урсгалыг нэг файлд нэгтгэхийг зөвшөөрөх Татаж авсан файлуудаас хайх Хайх Автоматаар орчуулсан хадмал орчуулга Бүх хэл дээр автоматаар орчуулагдсан хадмал орчуулга татаж авах боломжтой. Эдгээр хадмал орчуулга нь буруу, ойлгоход хэцүү байж магадгүй. Автомат форматаар татаж авах хадмал орчуулгын хэлийг таслалаар тусгаарлана. Дараагийн татаж авахдаа санаарай Харж, мэдэр Интерфейс ба харилцан үйлчлэл Өмнөх сонголтыг ашиглана уу Байхгүй Дахин тохируулах Хадмал орчуулгаас хайх Үгүй баярлалаа Дараах хэлүүд нь таны дараагийн татан авалтын сонголтод нэмэгдэх болно: Хадмал орчуулгын хэлийг шинэчлэх үү? Экспорт Импорт Бүрэн нөөцлөлт Нөөцлөх төрөл руу экспортлох Файл Түр санах ой -аас импортлох Татаж авсан түүхийг экспортлох уу? Татаж авсан түүхийг импортлох уу? Татаж авсан файлуудыг импортлохгүй. Та тэдгээрийг гараар буцааж татах шаардлагатай болно Татаж авсан түүхээс %1$s-г экспорт хийж байна. Татаж авсан файлууд болон тохиргоог нөөцлөхгүй. Татаж авах түүх Түүхийг татахын тулд %1$s-г импортолсон Дахин татаж авах Видеог татаж авлаа. Хэрэв энэ нь хүлээгдэж буй үйлдэл биш бол татаж авах архиваа шалгана уу. Remux видео сав Илүү сайн нийцтэй байхын тулд Remux видеог MKV саванд хийнэ Нийт %2$d вэбсайтаас %1$d күүки Өдөр бүр Долоо хоног бүр Сар бүр Бүх хэл Тоглуулах жагсаалт Үргэлжлүүлэх Урьдчилан тохируулсан %1$s-г илүүд үзнэ үү Формат, хадмал орчуулгаас сонгоод цааш нь тохируулаарай Өөрийн форматын тохиргоог ашиглан автоматаар татаж аваарай Урьдчилсан тохиргоог засах Боломжтой хамгийн сайн форматыг татаж аваарай Даалгаврыг дараалалд нэмсэн %d зүйл %d зүйл %d аудио %d аудио Татах дараалал Та эндээс татан авалтуудаа олох болно Татаж авах товчийг дарах эсвэл энэ програмын видео холбоосыг хуваалцаж, татаж авч эхэлнэ үү Татаж авсан %1$d холбоосоос сонгоно уу Алдааг олж засварлах Асуудал хянагч Нийтлэг алдааг засч, мэдэгдэж буй асуудлуудыг шалгана уу Алдаа гарсан уу? Шинэ асуудлыг мэдээлэхийн өмнө манай асуудал хянагчаас хайна уу. Тэнд олон нийтлэг асуудлыг аль хэдийн шийдэж, баримтжуулсан. Навигацийн цэсийг харуулах Үргэлжлүүлэх Устгах Бүгд Медиа мэдээлэл ================================================ FILE: app/src/main/res/values-mr/strings.xml ================================================ ऑडिओ म्हणून सेव्ह करा सिस्टम सेटिंग्ज व्हिडिओ फोल्डर थंबनेल सेव्ह करा व्हिडिओ ऐवजी ऑडिओ डाउनलोड आणि सेव्ह करा विद्यमान डाउनलोड कार्य आधीच चालू आहे सामान्य, स्वरूप, सानुकूल कमांड डाउनलोड नवीनतम yt-dlp आवृत्ती स्थापित करू शकलो नाही. कृपया तुम्ही इंटरनेटशी कनेक्ट असल्याची खात्री करा. व्हिडिओ माहिती मिळवत आहे… फाईल डाऊनलोड करता आली नाही व्हिडिओ माहिती मिळवता आली नाही भाषा प्रदर्शित करा लिंक रिकामी असू शकत नाही व्हिडिओ थंबनेल फाईल म्हणून सेव्ह करा yt-dlp ची नवीनतम आवृत्ती वापरत आहे परवानगी नाकारली डाउनलोड पूर्ण झाले तुमच्या डाउनलोड इतिहासातून \"%1$s\" काढून टाकायचे\? रद्द करा डाउनलोड \"%1$s\" डाउनलोड करा सामान्य क्लिपबोर्डवरून URL पेस्ट करा काढायचे\? प्रदर्शन भाषा सेट करा क्लिपबोर्डमधील URL जुळू शकलो नाही Yt-dlp आवृत्ती नवीनतम yt-dlp आवृत्ती स्थापित करण्यासाठी क्लिक करा क्रेडिट्स आणि लिब्रे सॉफ्टवेअर पुष्टी करा त्रुटी अहवाल क्लिपबोर्डवर कॉपी केला ऑडिओ लिंक ओपन करा डिलीट करा मागे तपासले डाउनलोड करण्यापूर्वी कॉन्फिगर करा लिंक क्लिपबोर्डवर कॉपी केली काढा आवृत्ती सानुकूल कमांड आमच्याबद्दल क्रेडिट्स आवृत्ती, फीडबॅक, ऑटो अपडेट चेंजलॉग आणि नवीन आवृत्त्या पहा GitHub रेपॉजिटरी आणि README तपासा सानुकूल टेम्पलेटसह yt-dlp कमांड चालवा कमांड टेम्पलेट तपशीलवार आउटपुट हे डाउनलोड समायोजित करा नवीनतम रिलीज व्हिडिओ रद्द करा Yt-dlp वापर संदर्भ कमांड कार्यान्वित करण्यास प्रारंभ करा अडवांस डाउनलोड करताना तपशीलवार संदेश प्रिंट करा डिस्प्ले चालू पेस्ट करा अपरिवर्तित संपादित करा गडद थीम, डायनॅमिक रंग, भाषा बंद गडद थीम डाउनलोड करण्यापूर्वी प्राधान्ये कॉन्फिगर करा थंबनेल स्वरूप सर्वोत्तम गुणवत्ता (डीफॉल्ट) आउटपुट मार्ग आणि URL ॲपद्वारे जोडले जातील. ऑडिओ स्वरूप रूपांतरित करा %1$s मध्ये रूपांतरित करा एकाधिक उपस्थित असताना व्हिडिओ गुणवत्ता मर्यादित करा व्हिडिओ गुणवत्ता ऑडिओ फाइल्स पुन्हा एन्कोडिंग केल्याने ऑडिओ गुणवत्तेत नुकसान होईल आणि फाइलचा आकार वाढेल. अपडेट yt-dlp व्हिडिओ चा स्वरूप बंद पून्हा दाखवू नका यूजर गाईड सेटिंग्ज उघडा क्लिप-बोर्डवरून व्हिडिओ लिंक मिळविण्यासाठी \"पेस्ट\" वर क्लिक करा अधिक व्हिडिओ साठी तुमचा आवडीचा स्वरूप कोणता रूपांतर डाऊनलोड निर्दिष्ट नाही आवडीचा व्हिडिओ स्वरूप ================================================ FILE: app/src/main/res/values-ms/strings.xml ================================================ Folder video Simpan lakaran kenit Tetapan Umum, format, arahan tersuai Muat turun dan simpan audio, bukannya video Simpan lakaran kenit video sebagai fail Tidak dapat memasang versi yt-dlp terkini. Sila pastikan bahawa anda disambungkan ke Internet. Mengumpul maklumat video… Muat turun selesai Tidak dapat memuat turun fail Muat turun \"%1$s\" Tidak dapat mengumpul maklumat video Umum Tugas muat turun sedia ada sedang berjalan Tidak dapat memadankan URL dalam papan keratan Tekan untuk memasang versi yt-dlp terkini Buang\? Padam Perihal Versi,maklum balas,kemas kini automatik Kembali Periksa repositori GitHub dan README Video Ditanda Penghargaan Penghargaan dan perisian bebas Arahan tersuai Templat arahan Mulakan arahan tersuai Lanjutan Hidupkan Matikan Pelarasan sebelum muat turun Pelarasan pilihan sebelum memuat turun Laraskan muat turun ini Muka paparan Tampal Simpan sebagai audio Pautan tidak boleh dibiarkan kosong Muat turun Menggunakan versi yt-dlp terkini Kebenaran dinafikan Tetapkan bahasa paparan Bahasa paparan Versi yt-dlp Tampal URL daripada papan keratan Buang \"%1$s\" daripada sejarah muat turun anda selama-lamanya\? Sahkan Batalkan Muat turun Audio Pautan disalin ke papan keratan Buka pautan Versi Buang Cari log perubahan dan versi baharu Keluaran terkini Tunjukkan mesej terperinci ketika memuat turun Paparan Sunting Jalankan yt-dlp dengan templat tersuai Output terperinci Laluan output dan URL akan ditambah oleh aplikasi. Tema gelap, warna dinamik, bahasa Tema gelap Sistem Batal Rujukan penggunaan yt-dlp Laporan ralat disalin ke papan keratan Ubah format audio Tidak boleh diubah Ubah ke %1$s Format Qualiti terbaik (tetapan asal) Hadkan kualiti video apabila terdapat banyak Tidak dinyatakan (tetapan asal) Format video yang digemari Format yang dipilih apabila terdapat pelbagai pilihan Muat turun Tutup Jangan tunjuk untuk selama-lamanya Panduan pengguna Buka tetapan Tekan \"Tampal\" untuk dapatkan pautan video daripada papan keratan. Periksa dan uruskan muat turun, termasuk video and audio. Periksa versi yt-dlp dalam tetapan adalah terbaharu sebelum menggunakannya. Tetapan asal Muat turun Maklumkan tentang perkembangan dan fail yang telah dimuat turun Pautan video Muat turun selesai. Tekan untuk buka. Memulakan arahan tersuai… Muat turun secara pelbagai benang Muat turun bahagian-bahagian video bagi M3U8/MPD secara serentak %d proses akan digunakan untuk memuat turun video bagi DASH/HLS secara serentak. Pilihan Tidak dapat memadankan URL daripada isi perkongsian Membaca pautan video daripada isi perkongsian… Tunjukkan lebih lagi aksi Pemberitahuan muat turun Maklumkan perkembangan dan fail yang telan dimuat turun Mengumpul maklumat senarai-main… Pilihan senarai-main Nyatakan julat bagi video untuk dimuat turun daripada senarai-main \" %3$s\" (dari %1$d ke %2$d). Mula Selesai Julat index tidak sah Memuat turun senarai main (%1$d/%2$d), tekan untuk berhenti. Folder audio Folder muat turun Simpan ke dalam anak folder Simpan fail ke dalam folder yang bernama berasaskan laman sesawang Masalah keizinan bagi peti simpanan Folder selain Download and Document tidak disokong Konfigurasi bateri Abaikan pengoptimuman bateri bagi aplikasi ini untuk membolehkannya berjalan di latar belakang Seal sedang memuat turun… Terjemah Bantu terjemah aplikasi ini on Hosted Weblate Templat laluan Benamkan seri kata Label Buang\? Buang \"%1$s\" daripada templat arahan untuk selama-lamanya\? Pilihan templat Tugas muat turun dibatalkan Isu Github Laporkan isu bermasalah atau minta ciri-ciri tertentu Buka fail Selesai Mula semula Eksport ke papan keratan Import daripada papan keratan Eksport %1$d templat Format video Kemudian tekan \"Muat turun\" selepas melaras tetapannya. Pengekodan semula fail audio akan menyebabkan kehilangan kualiti audio dan peningkatan saiz fail. Qualiti video Pilih tempat untuk menyimpan fail video dan audio Jika boleh, benamkan seri kata ke dalam video Ubah Muat turun beberapa video ke senarai-main Senarai-main muat turun Sila laras penggunaan bateri kepada \"Tidak terhad\" untuk aplikasi ini dalam tetapan sistem bagi membolehkannya berjalan dibelakang. Masalah tidak diketahui Muat turun sedang berjalan, tekan untuk batal. Tetapan tambahan Aturan Templat baharu Edit dan uruskan templat arahan Maklumat disalin ke papan keratan Memuat turun Dibatalkan Mungumpul maklumat Ralat Salin pautan Lapor ralat/masalah Resolusi video Saiz fail video Import %1$d templat Buang %1$d benda daripada sejarah muat turun untuk selama-lamanya\? Buang segmen daripada video dengan API SpnsorBlock Kategori SponsorBlock %1$d Tugas muat turun Nyatakan kategori SponsorBlock untuk dibuang daripada video Ditambah Baru-baru Ini %1$d video, %2$d audio Pilih semua %1$d dipilih Semak versi terkini di GitHub secara automatik Tema gelap berkontras tinggi Input tidak sah Kualiti terendah Format file, kualiti video, sari kata Kadar had, pemuat turun, kuki Lumpuhkan pratonton Lumpuhkan pratonton untuk semua muat turun Privasi Gunakan perintah tersuai Direktori peribadi Simpan muat turun dalam direktori tersembunyi potong karya seni Potong imej terbenam dalam segi empat Semak kemas kini Versi ini adalah paling terkini Gunakan aria2c sebagai pemuat turun luaran Berjaya memadam %1$d fail sementara Mod berbilang pilih Mod peribadi Lumpuhkan sejarah muat turun Gunakan kuki berformat Netscape untuk muat turun Kosongkan fail sementara Kosongkan semua fail sementara daripada direktori dalaman Fail-fail sementara boleh digunakan untuk meneruskan muat turun yang telah dibatalkan. Adakah anda pasti untuk membuang semua\? Warna dinamik Guna warna-warna dari kertas dinding sebagai tema aplikasi Rangkaian Had kadar Kurangkan kadar maksimum untuk muat turun Kadar maksimum Gagal mengemas kini ke versi terkini Kemas kini Memuat turun menggunakan rangkaian selular telah dilumpuhkan mengikut tetapan anda Muat turun menggunakan selular Benarkan muat turun berlaku ketika bersambung dengan rangkaian terhad Tidak tersedia Versi Yt-dlp, notifikasi, senarai main Fail ini tidak lagi wujud Pilih video-video untuk dimuat turun dalam senarai main \"%1$s\" Video (tiada audio) Yang dicadang Pemilihan format Pilih format untuk dimuat turun sebelum memulakan muat turun Guna Kuki Muat turun kapsyen yang dijana secara automatik Kebanyakan platform penstriman video menyampaikan audio dan video secara berasingan, anda boleh pilih dan gabung format audio sahaja dengan format video sahaja kepada satu video. Edit jalan pintas tersuai yang boleh digunakan untuk mengarang templat arahan. Kuki Menjana kuki baharu Saluran Telegram Buang kuki untuk \"%1$s\"? Sebahagian pilihan tidak ada apabila menggunakan arahan tersuai Bagaimana ia berfungsi\? Ruang Matrix Memuat turun daripada sebahagian laman memerlukan maklumat pengesahan akaun. Tekan \"Menjana kuki baharu\",masukkan URL laman sesawang tersebut dan kemudian log masuk dengan akaun anda dalam muka surat pelayar, aplikasi tersebut akan menjananya untuk anda. Folder kad SD Kapsyen automatik Muat turun pantas Teks sampel tajuk video Teks sampel pencipta video Sari kata Muat turun sari kata Bahasa-bahasa sari kata Bahasa-bahasa, benam sari kata, kapsyen automatik Tiru log Bersihkan Edit jalan pintas Tambah Jalan pintas Menjalankan tugas Sari kata mungkin tersalah masa apabila membuang segmen SekatPenaja. Tunjuk log Log %.2f M Buang Untuk membenam sari kata, video akan diremux ke dalam bekas MKV. %.2f G Kongsi Stabil Pratonton Pasang binaan prakeluaran untuk pratonton ciri dan perubahan baharu. \n \nAkan terdapat sebahagian ketidakstabilan dalam versi-versi ini, jadi sila jangan teragak-agak untuk memberi kami maklum balas jika anda mengalami sebarang masalah untuk membantu kami menambah baik aplikasi untuk masa hadapan. Saluran kemas kini Kemas kini automatik Membenarkan kemas kini automatik Guna Kadar bit terendah Format audio pilihan Penyusunan format Import Tajuk Format penyusunan dengan pilihan -S yt-dlp minit Menama semula kedua Bersihkan semua kuki Buang semua kuki yang disimpan dalam aplikasi selama-lamanya\? Format audio Tiada media dimuat turun Beta Membenarkan ciri percubaan\? Buat klip video dalam muka surat pilihan format Kemas kini automatik tidak ada untuk binaan %1$s. Jika anda tiada %1$s dipasang pada peranti anda, atau anda ingin pratonton ciri baharu yang akan datang dalam Seal, sila pertimbangkan %2$s. bertukar kepada binaan GitHub Baik Faham Ciri tidak ada Mula Tamat Tidak terhad Kualiti audio Hadkan kadar bit audio apabila terdapat pelbagai kualiti Sokong aplikasi ini dengan menaja di Github Muat turun mengguna ciri ini akan diwakilkan kepada FFmpeg untuk memuat turun bahagian video yang dipilih, ciri ini masih dalam percubaan dan pemotongan tidak akan menjadi tepat sepenuhnya, tidak semua formag sokong ciri ini dan anda mungkin akan mengalami kelajuan muat turun yang lebih perlahan. Video klip Simpan fail sementara dalam direktori dalaman Taja Maklum balas Penaja Seal akan sentiasa menjadi percuma dan sumber terbuka untuk semua orang. Jika anda suka, sila pertimbangkan untuk menaja saya di GitHub! ================================================ FILE: app/src/main/res/values-nb/strings.xml ================================================ Bekreft Fjern «%1$s» fra nedlastningshistorikken for godt\? Ukonvertert Videolenke Angi område av videoer å laste ned fra %3$s-spillelisten (fra %1$d til %2$d)). Start Slutt Lydmappe Nedlastningsmappe Velg hvor video- og lydfiler skal lagres Lim inn nettadresse Yt-dlp -versjon Fjern\? Avbryt Nedlastninger Lenke kopiert til utklippstavlen Åpne lenke Begynn kjøring av kommando Mørk drakt Avbryt miniatyrbilde Lim inn Konverter lydformat Videokvalitet Videoformat Lukk Åpne innstillingene Last ned spilleliste Forvalg Laster allerede ned noe … Kjør yt-dlp -kommando med egendefinert mal Visning Ukjent feil Skriv ut detaljerte meldinger ved nedlasting Valg av spilleliste Ugyldig indeksområde Lagre i undermappe Bruksreferanse for yt-dlp Problem med lagringstilgang Lagre filer i mapper med respektive nettsidenavn Klikk på «Last ned»-etter å ha justert innstillingene dens. Merknad om framdrift og nedlastede filer Lagre minatyrbilde Last ned Lagre videominiatyrbilde som en fil Henter videoinfo … Nedlastet Kunne ikke hente videoinfo Visningsspråk Sett visningsspråk Fjern Slett Versjon, utgivelser, bidragsytere Versjon Video Bidragsytere Avansert System Ikke angitt (forvalt) Nedlastingsmerknad Bruker nyeste versjon av yt-dlp Se etter endringslogger og nye versjoner Mørk drakt, dynamisk farge, språk Seal laster ned … Foretrukket videoformat Valgt format når flere tilbys Klikk «Lim inn» for å hente videolenken fra utklippstavlen din. Sjekk og håndter nedlastninger i programmet, inkludert video- og lydfiler. Merknad om nedlastingsframdrift og fullføring Sett opp innstillinger før nedlastning Juster denne nedlastingen Feilrapport kopiert til utklippstavlen Lyd i MP3 eller M4A-format fungerer på de fleste enheter Ignorer batterioptimaliseringer for bakgrunnsnedlastinger i dette programmet Mapper utenfor Download/ og Documents/ støttes ikke Alternativer Ytterligere innstillinger Last ned flere deler Last ned flere deler av M3U8/MPD-videoer samtidig Innstillinger Lenken kan ikke være tom Kunne ikke laste ned fil Om Tilbake Egendefinert kommando Rediger Detaljert utdata Av Beste kvalitet (forvalg) Brukerveiledning Last ned flere videoer fra en spilleliste Vis flere handlinger Sjekket Bidragsytere og fri programvare Sett opp før nedlasting Batterioppsett Ikke vis igjen Kjører egendefinerte kommandoer … Begrens formatkvaliteten til dette nivået Sett batteribruk for dette programmet til «Ubegrenset» i systeminnstillingene for nedlasting i bakgrunnen. Oversett Bistå oversettelsen på Hosted Weblate Videomappe Lagre som lyd Generelt, format, egendefinert kommando Last ned og lagre lyd istedenfor video Last ned «%1$s» Klikk for å installere den nyeste yt-dlp-versjonen Generelt Lyd Kommandomal Format Konverter til %1$s Last ned Henter spillelisteinfo … Klarte ikke å installere den nyeste yt-dlp-versjonen. Sørg for at du er tilkoblet til Internett. Nyeste utgave Tilgang nektet Sjekk GitHub-kodelageret og LESMEG-filen Kunne ikke jamføre med nettadressen i utklippstavlen Last ned Konverter Ta en titt på nedlastningsinnstillingene og ha nyeste utgave av yt-dlp før bruk. Nedlastning fullført. Klikk for å åpne. Laster ned spilleliste (%1$d/%2$d). Klikk for å stoppe. Utdatasti og nettadresse blir lagt til av programmet. Kunne ikke jamføre nettadresse fra delt innhold Leser videolenke fra delt innhold … %d tråd(er) kan bli brukt til å laste ned DASH/HLS native-video samtidig. Stimal Legg inn undertekster Legg til undertekster hvis de finnes Fjern\? Fjern «%1$s» fra kommandomalene for godt\? Ny mal Etikett Malvalg Rediger og håndter kommandomaler Nedlastingsoppgave avbrutt Laster ned … Klikk for å avbryte. GitHub-feilrapport Send inn en feilrapport om problemer eller ønskede funksjoner Info kopiert til utklippstavle Se etter nye versjoner Bruk aria2c som ekstern nedlaster Klarte ikke å installere den nyeste versjonen Installer Eksporterte %1$d mal(er) Kopier lenke Videofilstørrelse Eksporter til utklippstavlen Importerte %1$d mal(er) %1$d video(er), %2$d lydfil(er) Fjern segmenter fra videoer med SponsorBlock-API-et Importer fra utklippstavlen %1$d nedlastede gjøremål Du kjører nyeste versjon Installer nyeste versjon fra GitHub Slettet %1$d midlertidig(e) fil(er) Midlertidige filer kan brukes til å gjenoppta avbrutte nedlastinger. Vil du fjerne alle disse filene\? Åpne fil Start på ny Tøm midlertidige filer Slett alle midlertidige filer fra nedlastingsmappen Bruk Netscape-formaterte kaker for nedlastinger Kølagt Fullført Laster ned Avbrutt Henter info … Feil Feilrapport Videooppløsning Nylig tillagt Fjern %1$d element(er) fra nedlastingshistorikken din for godt\? SponsorBlock-kategorier Angi SponsorBlock-kategorier å fjerne fra videofilen Privat modus Dynamisk farge Skru av miniatyrbilde- og nedlastingshistorikk Multivalgsmodus Bruk farger fra bakgrunnsbilder i programdrakt Mørk høykontrastdrakt Nettverk Nedlasting ved bruk av mobildata er avskrudd i programinnstillingene Begrens maksimal hastighet for nedlastinger Øvre tak for overføringen Maksimal hastighet Tillat nedlasting av media når tilkoblet kvotebaserte nettverk Filen er ikke lenger tilgjengelig Last ned med mobildata Laveste kvalitet Ugyldig inndata Utilgjengelig Filformat, videokvalitet, undertekster Skru av forhåndsvisning Privat mappe Personvern Bruk egendefinert kommando Yt-dlp-versjon, merknad, spilleliste Skru av forhåndsvisning for nedlastinger Lagre nedlastninger i en skjult mappe Beskjær illustrasjon Beskjær innebygd bilde til kvadrat Overføringsterskler, nedlaster, kaker ================================================ FILE: app/src/main/res/values-nl/strings.xml ================================================ Geluidsbestanden in MP3 of M4A werkt op de meeste apparaten Videokwaliteit Beste kwaliteit Sla op als geluidsbestand Sla voorvertoning op Algemeen, bestandstype, aangepaste command Download Kon de nieuwste versie van yt-dlp niet installeren. Check of uw internetverbinding werkt. Video info ophalen… Toestemming geweigerd Download is klaar Downloaden van bestand is mislukt Ophalen video info is mislukt Taal Stel een taal in Een andere download is al bezig Plak URL vanuit klembord Plakken URL vanuit klembord mislukt Klik om de nieuwste versie van yt-dlp te installeren Verwijderen\? ‘%1$s’ definitief uit uw downloadgeschiedenis verwijderen\? Bevestigen Geluidsbestand Link gekopieerd naar klembord Verwijderen Bestand wissen Over Zoek naar wijzigingslogs en nieuwe versies Nieuwste versie Check de GitHub repository en de README Video Credits Credits en libre software Voer yt-dlp command uit met een aangepaste sjabloon Uiterlijk Donker thema, dynamische kleuren, talen Donker thema Laat gedetailleerde berichten zien tijdens het downloaden Annuleren Configureer vóór het downloaden Configureer voorkeuren vóór het downloaden Pas deze download aan Foutrapport gekopieerd naar klembord Plakken Yt-dlp gebruiksreferenties Uitvoerpad en URL zullen door de app worden toegevoegd. Geluidsbestand converteren Niet converteren Converteren naar %1$s Bestandstype Beperk de videokwaliteit wanneer meerdere opties aanwezig zijn Niet gespecificeerd (standaard) Voorkeur type videobestand Voorkeur bestandstype bij meerdere opties Type videobestand Converteren Downloaden Instellingen openen Klik daarna op ‘Downloaden’ na het aanpassen van de instellingen. Controleer en beheer in-app downloads, inclusief video\'s en geluidsbestanden. Afspeellijst downloaden Downloaden Videolink Stel het batterijgebruik van deze app in op ‘Onbeperkt’ in de systeeminstellingen om op de achtergrond te kunnen downloaden. Meerdradige download Opties Extra instellingen Delen van URL mislukt Meer acties weergeven Afspeellijstinfo ophalen… Selectie afspeellijst Begin Ongeldige indexreeks Audio map Map downloaden Opslaan in subdirectory Sla bestanden op in mappen met de naam van de velden Probleem met opslagmachtiging Batterijconfiguratie Seal is bezig met downloaden… Video map Instellingen Gecheckt Dit vak moet ingevuld worden Download en sla geluidsbestand op, in plaats van videobestand Download ‘%1$s’ Sla voorvertoning van video op als een bestand Algemeen Annuleren U gebruikt de nieuwste versie van yt-dlp Downloads Versie yt-dlp Link openen Terug Versie, feedback, automatische updates Versie Aangepaste command Systeem Bewerken Uit Command sjabloon Start command uit te voeren Gedetailleerde output Aan Geavanceerd Voorvertoning Niet meer laten zien Gedownloade bestanden en voortgang melden Download voltooid. Klik om te openen. Meerdere delen van M3U8/MPD video\'s tegelijk downloaden Gedownloade bestanden en voortgang melden Gebruikershandleiding Sluiten Klik op ‘Plakken’ om de videolink uit uw klembord te halen. Bekijk de downloadinstellingen en zorg ervoor dat u de nieuwste versie van yt-dlp hebt voordat u deze app gebruikt. Download meerdere video\'s van een afspeellijst Standaard Aangepaste commands aan het uitvoeren… %d thread(s) worden gebruikt om DASH/HLS native video gelijktijdig te downloaden. Videolink aan het lezen vanuit gedeelde inhoud… Downloadnotificatie Afspeellijst (%1$d/%2$d) aan het downloaden, klik om te stoppen. Mappen buiten Download/ en Documents/ worden niet ondersteund Einde Geef de reeks video\'s op die u wilt downloaden van de afspeellijst ‘%3$s’ (van %1$d tot %2$d). Selecteer waar u video\'s en geluidsbestanden wilt opslaan Negeer batterijoptimalisatie voor deze app om op de achtergrond te kunnen downloaden Onbekende fout Vertalen Help deze app te vertalen op Hosted Weblate Path template De huidige versie is up-to-date Sjabloon selectie Verwijder of markeer segmenten van videos met SponserBlock API Update Haal tijdelijke bestanden weg Foutrapport Exporteer naar klembord Importeer van klembord %1$d sjabloon(s) geëxporteerd %1$d sjabloon(s) geïmporteerd Haal %1$d item(s) weg van uw downloadgeschiedenis voor altijd\? SponsorBlock categorieën De update naar de laatste versie is gefaald Gebruik aria2c als de externe downloader Cookies Kopieer link Videoresolutie Videobestand groote Dynamische kleur Incognito modus Controleer op updates %1$d download taken Onlangs Toegevoegd Multi-select modus Tijdelijke bestanden kunnen gebruikt worden om geannuleerde downloads opnieuw laten te beginnen. Weet je zeker dat je all deze bestanden wilt verwijderen\? Zet downloadgeschiedenis uit Nieuwe sjabloon Label Dit bestand is niet meer beschikbaar %1$d video(s), %2$d audiobestand(en) Weghalen\? Fout Wijzig en beheer de commando sjablonen Download in werking… Netwerk Begrenzing Sluit ondertitels in Verwerk voorziene ondertitels in video\'s indien beschikbaar Haal \"%1$s\" weg uit commando sjablonen voor altijd\? Specificeer SponserBlock categorie om te verwijder of markeren van het videobestand Download met mobiele data Voltooid Begin opnieuw In de wachtrij geplaatst Gebruik Netscape geformatteerde cookies voor downloads Controleer voor de laatste versie up GitHub automatisch Verwijder alle tijdelijke bestanden van het interne map %1$d tijdelijke bestand(en) verwijderd Info gekopieerd naar klembord Download taak beïndigt Dien een probleem in voor bug report of functie verzoek GitHub probleem Update yt-dlp Onderbroken Informatie aan het ophalen Bestand openen Aan het downloaden ================================================ FILE: app/src/main/res/values-nn/strings.xml ================================================ Videomappe Gøym som ljod Innstillingar Vanleg, format, eigne påbod Hent Lenka kan ikkje vera tom Løyve ikkje gjeve Henta Kunne ikkje hente fila Vanleg Visingsmål Vel visingsmål Limde inn ei lenke ifrå utklippstavla Tak bort\? Stadfest Ljod Lenka kopiert til utklippstavla Opne lenka Tak bort Slett Køyr yt-dlp påbod med eigne malar Påbodmal Brigde Avansert Vising Av Mistakrapport kopiert til utklippstavla småbilete Lim inn Tilråding for bruk av yt-dlp Format Omlaging av ljodfiler fører til tap av ljodkvalitet og auking av filstorleik. Oppløysing Høgst mogleg (forval) Avgrens oppløysinga til videoar Ikkje oppgjeve (forval) Ynskt videoformat Valt format når fleire vert tilbodne Hent og gøym ljod i staden for video Hentar videoopplysingar… Hent «%1$s» Kunne ikkje hente videoopplysingar Ei henteføreloge finst og køyrar alt Avbryt Tak bort «%1$s» ifrå hentingshistorikken din for godt\? Hentingar Eigne påbod Om Attende Video Utfør påbodet Avbryt System Utdatasti og nettadresse vert lagt til av appen. Videoformat Hent Lag om Lat att Ikkje vis att Opne innstillingane Gøym småbilete Gøym videosmåbilete som ei fil Kunne ikkje leggja inn den nyaste yt-dlp-utgåva. Syrg for at du har samanbinding til Internet. Fleire innstillingar Les videolenka ifrå delt innhald… Vis fleire gjerder Hentingsmerknad Spelelisteutval Oppgje området av videoar som skal verta henta ifrå «%3$s»-spelelista (ifrå %1$d til %2$d). End Ljodmappe Vel kvar du vil gøyma video- og ljodfiler Gøym i undermappe Gøym filer i mapper kalla opp etter nettstadane filene kjem ifrå Problem med gøymetilgjenget Batteri-innstilling Hopp over batteriforlenging for denne appen, slik at han kan hente ting i bakgrunnen Seal hentar… Ukjent mistak Omsett Hjelp til med å omsetja denne appen hos Hosted Weblate Stigmal Bygg inn teksting Om tilgjengelege, bygg gjevne undertekster inn i videoar Malnamn Tak bort\? Malval Brigd og handsam påbodmalar Hentingsføreloga avbroten Github-mistakrapport Opplysingar kopiert til utklippstavla Lagt til i venterekkja Utført Hentar Avbroten Hentar opplysingar Videooppløysing Innførte %1$d mal(ar) %1$d henta føreloger %1$d video(ar), %2$d ljodfil(er) Tak bort %1$d ting ifrå hentingshistorikken din for godt\? SponsorBlock-slag Bruk informasjonskapslar i Netscape-formatet for hentingar Tøm mellombels filer Sletta %1$d mellombels fil(er) Fleirvalmodus Privat modus Mapper utanfor Download/ og Documents/ er ikkje stødde Gje merknad om henta filer og framdrift Hentar spelelisteopplysingar… Byrja Hentar spelelista (%1$d/%2$d), trykk for å stogga. Hentingsmappe Ny mal Tak bort «%1$s» ifrå påbodmalane for godt\? Hentar, trykk for å avbryta. Mistak Byrja om att Opne fila Kopier lenka Mistakrapport Videofilstorleik Utfør til utklippstavla Innfør ifrå utklippstavla Utførte %1$d mal(ar) Nyleg lagt til Oppgjev kva slags SponsorBlock-slag som skal verte tekne bort ifrå videofila Bruk aria2c som den ytre hentaren Slett alle mellombels filer ifrå hentingsmappa Mellombels filer kan verta nytta til å halde fram med avbrotne hentingar. Er du trygg på at du vil slette alle desse filene\? Slå av hentingshistorikk Hent med mobildata Fila er ikkje lenger tilgjengeleg Nettverk Ikkje tilgjengeleg Slå av førehandsvising Personvern Henting på mobildata er slått av ifylgje dine innstillingar Lægst mogleg Slå av førehandsvising på hentingar Filformat, videokvalitet, teksting Mørk vising, skiftande létar, mål Mørk vising Privat mappe Gøym hentingar i ein skjult mappe Kutt kunstverk Skjer innbygt bilete til eit kvadrat Vel videoar som skal verta henta ifrå spelelista «%1$s» Vel alle/alt %1$d vald(e) Bruk eigne påbod Nyttar nyaste utgåve av yt-dlp Kunne ikkje jamføra med nettadressa i utklippstavla Yt-dlp-utgåve Trykk for å leggja inn den nyaste utgåva av yt-dlp Utgåve Nyaste utgåve Gransk GitHub-kodegøymet og LESMEG-fila Granska Sett opp før henting Sett opp innstillingar før henting Lag om ljodformatet Ikkje omlaga Lag om til %1$s Brukarrettleiing Trykk på «Lim inn» for å hente videolenka ifrå utklippstavla di. Tak og so sjå på hentingsinnstillingane og syrg for at du har den nyaste utgåva av yt-dlp før du nyttar han. Hent speleliste Hent fleire videoar ifrå ei speleliste Føreval Hent Varsle om henta filer og framdrift Videolenke Utgåve, tilbakemelding, sjølvoppdater Sjå etter brigdelister og nye utgåver Gransk og handsam hentingar i appen, gjeld au videoar og ljodfiler. Henta. Trykk for å opne. Køyrer eigne påbod… Lat appen sjå etter den nyaste utgåva på GitHub av seg sjølv Denne utgåva er nyast Legg inn den nyaste utgåva Skiftande letar Nytta letar ifrå bakgrunnsbilete i appvisinga Kunne ikkje leggja inn den nyaste utgåva Takk til Hjelpeytarar og fri programvare Trådar i bruk ved henting %d tråd(ar) vil verte nytta til å hente DASH/HLS-innebygd video samstundes. Val Send inn ein mistakrapport om problem eller ynskte funksjonar Yt-dlp-utgåve, merknad, speleliste Hent fleire delar av M3U8/MPD-videoar samstundes Ugildt indeksområde Video (ingen ljod) Tilrådd Vis formatval før ei henting byrjar Formatval Detaljert utdata Skriv ut detaljerte opplysingar ved henting Om du vil hente i bakgrunnen, sett denne appens batteribruk til «uavgrensa» i systeminnstillingane. Tak bort stødde delar ifrå videoar med SponsorBlocks API Sjå etter oppdateringar Svart vising Bruk infokapslar Tak bort infokapslar for «%1$s»? Nokre val er utilgjengelege når « bruk eigne påbod» er slegen på Korleis verkar det\? Henting ifrå somme nettstadar krev kontostadfestingsopplysingar. Trykk på «lag nye infokapslar», skriv inn nettadressa til nettstaden, og logg deg so inn i nettlesersida som appen fører deg til. Telegram-sambandsline Lag nye informasjonskapslar Gje løyve til henting av media når sambunden til forbruksmålte nettverk Still inn denne hentinga Trykk so på «hent» etter å ha stilt inn innstillingane. Kunne ikkje samsvare nettadresse ifrå delt innhald Informasjonskapslar Ugild innmating Set ei grense for kvar kjapt hentingar kan gå Matriserom Rateavgrensing Øvre rategrense Rategrense, hentar, infokapslar %.2f MB %.2f GB Ved borttaking av SponsorBlock-delar vert kan henda tekstingar ikkje tidfeste rett. Del Stødug Førehandsvising Logg Tekstingsmål Mål, bygg inn teksting, direkteteksting Kopier loggen Kvik henting Tøm Legg til Mappa til ytre gøyme Videonamn Videoskapar Teksting Hent tekstingar Køyrande føreloger Vis loggen Videoar vert satt inn i mkv-kar når tekstingar vert bygde inn. Byrjing Ende Klipp video Dei fleste videostraumingsplattformer sender åtskilde ljodar og videoar. Vel eit ljodformat og eit videoformat utan ljod for å slå dei saman til ei videofil. Brigd snarpåbod Snarpåbod Brigd snare påbod. Desse kan du nytte når du ritar påbodmalar. Uavgrensa Lægst bitrate Ljodkvalitet Avgrens ljodkvaliteten Namn Byt namn Før inn Sekund Ljodformat Det som har vorte henta vert vist her Beta Slå på røynd funksjon\? Melding ifrå utviklaren Mange takk! Sjølvoppdatering er ikkje tilgjengeleg for %1$s-utgåver. Om du ikkje har %1$s lagt inn på eininga di, eller vil røyna komande funksjonar, tak ein titt på %2$s. bytt til GitHub-utgåver Greitt Skjønar Funksjonen er ikkje tilgjengeleg Seal vil for alltid vera kostnadsfri og ha ein open kjeldekode. Om du vil kan du stø meg på GitHub. Sjølvoppdater Slå på sjølvoppdatering Ynkst ljodformat Minutt Tøm alle infokapslar Slett alle infokapslane gøymde i appen\? Stø Stø appen ved å gje pengar gjennom GitHub Tilbakemelding Støarar ================================================ FILE: app/src/main/res/values-or/strings.xml ================================================ ସଂସ୍କରଣ ଶ୍ରେୟ ସିଷ୍ଟମ୍ ଚାଲୁ ଡିଫଲ୍ଟ ସେଟିଂ ଅଡ଼ିଓ ସମ୍ବନ୍ଧରେ ସାଧାରଣ ଵିକଶିତ ଵିଡ଼ିଓ ସମ୍ପାଦନା ପ୍ରଦର୍ଶନ ବନ୍ଦ ଵିକଳ୍ପ ଅନୁଵାଦ କରନ୍ତୁ ତ୍ରୁଟି ଅପରିଚିତ ମୋଡ୍ ଫାଇଲ୍ ଡାଉନଲୋଡ୍ କରିହେଲା ନାହିଁ ଅଡିଓ ଭାବରେ ସେଭ୍ କରନ୍ତୁ ଡାଉନଲୋଡ୍ ଲିଙ୍କ ଖାଲି ରହିପାରିବ ନାହିଁ ଭିଡିଓର ଅପେକ୍ଷା ଧ୍ୱନିକୁ ଡାଉନଲୋଡ୍ ଓ ସଞ୍ଚୟ କରନ୍ତୁ ଭିଡିଓର ଥମ୍ନେଲ୍‌କୁ ଏକ ଫାଇଲ୍ ଭାବରେ ସଞ୍ଚୟ କରନ୍ତୁ ନୂତନ ସଂସ୍କରଣର yt-dlp ବ୍ୟବହାର କରନ୍ତୁ ଭିଡିଓ ସୂଚନା ଆହରଣ କରାଯାଉଛି… ଭିଡିଓ ସୂଚନା ଆହରଣ କରିପାରିଲା ନାହିଁ ଭାଷା ଦେଖାନ୍ତୁ ଅନୁମତି ଅସ୍ବୀକୃତ ଡାଉନଲୋଡ୍ ସମ୍ପୂର୍ଣ୍ଣ ହେଲା ================================================ FILE: app/src/main/res/values-pa/strings.xml ================================================ ਵੀਡੀਓ ਫੋਲਡਰ ਆਡੀਓ ਦੇ ਰੂਪ ਵਿੱਚ ਸੁਰੱਖਿਅਤ ਕਰੋ ਥੰਮਨੇਲ ਸੁਰੱਖਿਅਤ ਕਰੋ ਸੈਟਿੰਗਾਂ ਡਾਊਨਲੋਡ ਕਰੋ ਆਡੀਓ ਫਾਈਲਾਂ ਨੂੰ ਮੁੜ-ਇਨਕੋਡਿੰਗ ਕਰਨ ਨਾਲ ਆਡੀਓ ਗੁਣਵੱਤਾ ਵਿੱਚ ਕਮੀ ਆਵੇਗੀ ਅਤੇ ਫਾਈਲ ਦੇ ਆਕਾਰ ਵਿੱਚ ਵਾਧਾ ਹੋਵੇਗਾ। ਕਸਟਮ ਟੈਮਪਲੇਟ ਨਾਲ yt-dlp ਕਮਾਂਡ ਚਲਾਓ ਵਿਸਤ੍ਰਿਤ ਆਉਟਪੁੱਟ ਡਾਊਨਲੋਡ ਕਰਨ ਵੇਲੇ ਵਿਸਤ੍ਰਿਤ ਸੁਨੇਹੇ ਪ੍ਰਿੰਟ ਕਰੋ ਡਾਊਨਲੋਡ ਕਰਨ ਤੋਂ ਪਹਿਲਾਂ ਸੰਰਚਨਾ ਕਰੋ ਇਸ ਡਾਊਨਲੋਡ ਨੂੰ ਵਿਵਸਥਿਤ ਕਰੋ ਵੀਡੀਓ ਗੁਣਵੱਤਾ Yt-dlp ਵਰਤੋਂ ਦੇ ਹਵਾਲੇ ਜਨਰਲ, ਫਾਰਮੈਟ, ਕਸਟਮ ਕਮਾਂਡ ਲਿੰਕ ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ ਵੀਡੀਓ ਦੀ ਬਜਾਏ ਆਡੀਓ ਨੂੰ ਡਾਊਨਲੋਡ ਕਰੋ \'ਤੇ ਸਹੇਜੋ ਵੀਡੀਓ ਥੰਮਨੇਲ ਨੂੰ ਇੱਕ ਫਾਈਲ ਵਜੋਂ ਸਹੇਜੋ yt-dlp ਦੇ ਨਵੀਨਤਮ ਸੰਸਕਰਣ ਦੀ ਵਰਤੋਂ ਕਰ ਰਿਹਾ ਨਵੀਨਤਮ yt-dlp ਵਰਜਨ ਨੂੰ ਸਥਾਪਿਤ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਿਆ। ਕਿਰਪਾ ਕਰਕੇ ਯਕੀਨੀ ਬਣਾਓ ਕਿ ਤੁਸੀਂ ਇੰਟਰਨੈੱਟ ਨਾਲ ਕਨੈਕਟ ਹੋ। ਵੀਡੀਓ ਜਾਣਕਾਰੀ ਪ੍ਰਾਪਤ ਕੀਤੀ ਜਾ ਰਹੀ ਹੈ… ਆਗਿਆ ਤੋਂ ਇਨਕਾਰ ਡਾਊਨਲੋਡ ਪੂਰਾ ਹੋਇਆ ਫ਼ਾਈਲ ਡਾਊਨਲੋਡ ਨਹੀਂ ਕੀਤੀ ਜਾ ਸਕੀ \"%1$s\" ਨੂੰ ਡਾਊਨਲੋਡ ਕਰੋ ਵੀਡੀਓ ਜਾਣਕਾਰੀ ਪ੍ਰਾਪਤ ਨਹੀਂ ਕੀਤੀ ਜਾ ਸਕੀ ਜਨਰਲ ਡਿਸਪਲੇ ਭਾਸ਼ਾ ਡਿਸਪਲੇ ਭਾਸ਼ਾ ਸੈੱਟ ਕਰੋ ਇੱਕ ਮੌਜੂਦਾ ਡਾਊਨਲੋਡ ਕਾਰਜ ਪਹਿਲਾਂ ਹੀ ਚੱਲ ਰਿਹਾ ਹੈ URL ਪੇਸਟ ਕਰੋ ਕਲਿੱਪਬੋਰਡ ਵਿੱਚ URL ਨਾਲ ਮੇਲ ਨਹੀਂ ਹੋ ਸਕਿਆ Yt-dlp ਵਰਜਨ ਨਵੀਨਤਮ yt-dlp ਵਰਜਨ ਨੂੰ ਸਥਾਪਤ ਕਰਨ ਲਈ ਕਲਿੱਕ ਕਰੋ ਹਟਾਉਣਾ ਹੈ\? ਕੀ ਤੁਹਾਡੇ ਡਾਊਨਲੋਡ ਇਤਿਹਾਸ ਵਿੱਚੋਂ \"%1$s\" ਨੂੰ ਚੰਗੀ ਤਰ੍ਹਾਂ ਹਟਾਉਣਾ ਹੈ\? ਪੁਸ਼ਟੀ ਕਰੋ ਰੱਦ ਕਰੋ ਡਾਊਨਲੋਡਸ ਆਡੀਓ ਲਿੰਕ ਕਲਿੱਪਬੋਰਡ \'ਤੇ ਕਾਪੀ ਕੀਤਾ ਗਿਆ ਲਿੰਕ ਖੋਲ੍ਹੋ ਹਟਾਓ ਫ਼ਾਈਲ ਮਿਟਾਓ ਦੇ ਬਾਰੇ ਸੰਸਕਰਣ, ਸੁਝਾਅ, ਆਟੋ-ਅੱਪਡੇਟ ਵਾਪਸ ਸੰਸਕਰਣ ਚੇਂਜਲੌਗ ਅਤੇ ਨਵੇਂ ਸੰਸਕਰਣਾਂ ਦੀ ਭਾਲ ਕਰੋ ਤਾਜ਼ਾ ਰਿਲੀਜ਼ ਗਿਟਹੱਬ ਰਿਪੋਜ਼ਟਰੀ ਅਤੇ ਮੈਨੂੰ ਪੜ੍ਹੋ ਚੈੱਕ ਕਰੋ ਵੀਡੀਓ ਚੈੱਕ ਕੀਤੀ ਕ੍ਰੈਡਿਟ ਕ੍ਰੈਡਿਟ ਅਤੇ ਮੁਫ਼ਤ ਸੌਫਟਵੇਅਰ ਕਸਟਮ ਕਮਾਂਡ ਕਮਾਂਡ ਟੈਮਪਲੇਟ ਸੰਪਾਦਿਤ ਕਰੋ ਕਮਾਂਡ ਚਲਾਉਣੀ ਸ਼ੁਰੂ ਕਰੋ ਐਡਵਾਂਸਡ ਡਿਸਪਲੇ ਗੂੜ੍ਹਾ ਥੀਮ, ਗਤੀਸ਼ੀਲ ਰੰਗ, ਭਾਸ਼ਾਵਾਂ ਗੂੜ੍ਹਾ ਥੀਮ ਸਿਸਟਮ ਬੰਦ ਚਾਲੂ ਰੱਦ ਡਾਊਨਲੋਡ ਕਰਨ ਤੋਂ ਪਹਿਲਾਂ ਤਰਜੀਹਾਂ ਦੀ ਸੰਰਚਨਾ ਕਰੋ ਤਰੁੱਟੀ ਰਿਪੋਰਟ ਕਲਿੱਪਬੋਰਡ \'ਤੇ ਕਾਪੀ ਕੀਤੀ ਗਈ ਥੰਮਨੇਲ ਆਉਟਪੁੱਟ ਮਾਰਗ ਅਤੇ URL ਐਪ ਦੁਆਰਾ ਜੋੜਿਆ ਜਾਵੇਗਾ। ਆਡੀਓ ਫਾਰਮੈਟ ਵਿੱਚ ਬਦਲੋ ਅਣਬਦਲਿਆ %1$s ਵਿੱਚ ਬਦਲੋ ਫਾਰਮੈਟ ਪੇਸਟ ਕਰੋ ਉੱਤਮ ਕੁਆਲਿਟੀ ਕਈ ਕੁਆਲਿਟੀਆਂ ਮੌਜੂਦ ਹੋਣ \'ਤੇ ਵੀਡੀਓ ਕੁਆਲਿਟੀ ਨੂੰ ਸੀਮਤ ਕਰੋ ਅਣਨਿਰਦੇਸ਼ਤ (ਡਿਫ਼ਾਲਟ) ਪਸੰਦੀਦਾ ਵੀਡੀਓ ਫਾਰਮੈਟ ਤਰਜੀਹੀ ਫਾਰਮੈਟ ਜਦੋਂ ਇੱਕ ਤੋਂ ਵਧੇਰੇ ਪ੍ਰਦਾਨ ਕੀਤੇ ਜਾਂਦੇ ਹਨ ਵੀਡੀਓ ਫਾਰਮੈਟ ਬਦਲੋ ਡਾਊਨਲੋਡ ਕਰੋ ਬੰਦ ਕਰੋ ਦੁਬਾਰਾ ਨਾ ਦਿਖਾਓ ਉਪਭੋਗਤਾ ਗਾਈਡ ਸੈਟਿੰਗਾਂ ਖੋਲ੍ਹੋ ਆਪਣੇ ਕਲਿੱਪਬੋਰਡ ਤੋਂ ਵੀਡੀਓ ਲਿੰਕ ਪ੍ਰਾਪਤ ਕਰਨ ਲਈ \"ਪੇਸਟ ਕਰੋ\" \'ਤੇ ਕਲਿੱਕ ਕਰੋ। ਇਸ ਦੀਆਂ ਸੈਟਿੰਗਾਂ ਨੂੰ ਐਡਜਸਟ ਕਰਨ ਤੋਂ ਬਾਅਦ ਫੇਰ \"ਡਾਊਨਲੋਡ\" \'ਤੇ ਕਲਿੱਕ ਕਰੋ। ਵੀਡੀਓ ਅਤੇ ਆਡੀਓ ਫਾਈਲਾਂ ਸਮੇਤ ਐਪ-ਵਿੱਚ ਡਾਊਨਲੋਡਾਂ ਦੀ ਜਾਂਚ ਅਤੇ ਪ੍ਰਬੰਧਨ ਕਰੋ। ਡਾਊਨਲੋਡ ਸੈਟਿੰਗਾਂ \'ਤੇ ਇੱਕ ਨਜ਼ਰ ਮਾਰੋ ਅਤੇ ਇਸਦੀ ਵਰਤੋਂ ਕਰਨ ਤੋਂ ਪਹਿਲਾਂ ਯਕੀਨੀ ਬਣਾਓ ਕਿ ਤੁਹਾਡੇ ਕੋਲ yt-dlp ਦਾ ਨਵੀਨਤਮ ਸੰਸਕਰਣ ਹੈ। ਪਲੇਲਿਸਟ ਡਾਊਨਲੋਡ ਕਰੋ ਇੱਕ ਪਲੇਲਿਸਟ ਤੋਂ ਕਈ ਵੀਡੀਓ ਡਾਊਨਲੋਡ ਕਰੋ ਡਿਫਾਲਟ ਡਾਊਨਲੋਡ ਕਰੋ ਡਾਊਨਲੋਡ ਕੀਤੀਆਂ ਫ਼ਾਈਲਾਂ ਅਤੇ ਪ੍ਰਗਤੀ ਬਾਰੇ ਸੂਚਿਤ ਕਰੋ ਵੀਡੀਓ ਲਿੰਕ ਡਾਊਨਲੋਡ ਪੂਰਾ ਹੋਇਆ, ਖੋਲ੍ਹਣ ਲਈ ਟੈਪ ਕਰੋ। ਕਸਟਮ ਕਮਾਂਡਾਂ ਚੱਲ ਰਹੀਆਂ ਹਨ… ਕਿਰਪਾ ਕਰਕੇ ਬੈਕਗ੍ਰਾਊਂਡ ਵਿੱਚ ਡਾਊਨਲੋਡ ਕਰਨ ਲਈ ਸਿਸਟਮ ਸੈਟਿੰਗਾਂ ਵਿੱਚ ਇਸ ਐਪ ਦੀ ਬੈਟਰੀ ਵਰਤੋਂ ਨੂੰ \"ਅਪ੍ਰਬੰਧਿਤ\" \'ਤੇ ਸੈੱਟ ਕਰੋ। ਮਲਟੀ-ਥਰੈੱਡਡ ਡਾਊਨਲੋਡ ਸਮਾਨਾਂਤਰ ਵਿੱਚ M3U8/MPD ਵੀਡੀਓ ਦੇ ਹੋਰ ਭਾਗਾਂ ਨੂੰ ਡਾਊਨਲੋਡ ਕਰੋ %d ਥ੍ਰੈੱਡ(s) ਨੂੰ ਇੱਕੋ ਸਮੇਂ DASH/HLS ਮੂਲ ਵੀਡੀਓ ਨੂੰ ਡਾਊਨਲੋਡ ਕਰਨ ਲਈ ਵਰਤਿਆ ਜਾਵੇਗਾ। ਵਿਕਲਪ ਵਾਧੂ ਸੈਟਿੰਗਾਂ ਚੁਣੋ ਕਿ ਵੀਡੀਓ ਅਤੇ ਆਡੀਓ ਫਾਈਲਾਂ ਕਿੱਥੇ ਸਟੋਰ ਕਰਨੀਆਂ ਹਨ ਹੋਸਟਡ ਵੈਬਲੇਟ \'ਤੇ ਇਸ ਐਪ ਦਾ ਅਨੁਵਾਦ ਕਰਨ ਵਿੱਚ ਮਦਦ ਕਰੋ ਮਾਰਗ ਟੈਮਪਲੇਟ ਉਪਸਿਰਲੇਖ ਸ਼ਾਮਲ ਕਰੋ ਨਵਾਂ ਟੈਮਪਲੇਟ ਲੇਬਲ ਹਟਾਉਣਾ ਹੈ\? ਟੈਮਪਲੇਟ ਚੋਣ ਡਾਊਨਲੋਡ ਜਾਰੀ ਹੈ… ਡਾਊਨਲੋਡ ਕਾਰਜ ਰੱਦ ਕੀਤਾ ਗਿਆ ਜਾਣਕਾਰੀ ਕਲਿੱਪਬੋਰਡ \'ਤੇ ਕਾਪੀ ਕੀਤੀ ਗਈ ਕਤਾਰਬੱਧ ਪੂਰਾ ਹੋਇਆ ਫਾਇਲ ਖੋਲੋ ਰੀਸਟਾਰਟ ਕਰੋ ਤਰੁੱਟੀ ਲਿੰਕ ਕਾਪੀ ਕਰੋ ਰਿਪੋਰਟ ਕਾਪੀ ਕਰੋ ਵੀਡੀਓ ਰੈਜ਼ੋਲਿਊਸ਼ਨ ਕਲਿੱਪਬੋਰਡ ਵਿੱਚ ਨਿਰਯਾਤ ਕਰੋ ਨਿਰਯਾਤ ਕੀਤਾ %1$d ਟੈਮਪਲੇਟ ਆਯਾਤ ਕੀਤਾ %1$d ਟੈਮਪਲੇਟ %1$d ਡਾਊਨਲੋਡ ਕਾਰਜ ਹਾਲ ਹੀ ਸ਼ਾਮਿਲ ਕੀਤਾ %1$d ਵੀਡੀਓ, %2$d ਆਡੀਓ ਫ਼ਾਈਲ(ਆਂ) ਕੀ ਤੁਹਾਡੇ ਡਾਊਨਲੋਡ ਇਤਿਹਾਸ ਵਿੱਚੋਂ %1$d ਆਈਟਮਾਂ ਨੂੰ ਚੰਗੀ ਤਰ੍ਹਾਂ ਹਟਾਉਣਾ ਹੈ\? ਸਪਾਂਸਰਬਲਾਕ API ਨਾਲ ਵੀਡੀਓ ਵਿੱਚ ਭਾਗਾਂ ਨੂੰ ਹਟਾਓ ਜਾਂ ਨਿਸ਼ਾਨ ਲਗਾਓ ਸਪਾਂਸਰਬਲਾਕ ਸ਼੍ਰੇਣੀਆਂ ਅੱਪਡੇਟ ਲਈ ਚੈੱਕ ਕਰੋ GitHub \'ਤੇ ਆਪਣੇ ਆਪ ਹੀ ਨਵੀਨਤਮ ਸੰਸਕਰਣ ਦੀ ਜਾਂਚ ਕਰੋ ਮੌਜੂਦਾ ਸੰਸਕਰਣ ਅੱਪ ਟੂ ਡੇਟ ਹੈ ਨਵੀਨਤਮ ਸੰਸਕਰਣ \'ਤੇ ਅੱਪਡੇਟ ਕਰਨ ਵਿੱਚ ਅਸਫਲ ਅੱਪਡੇਟ aria2c ਨੂੰ ਬਾਹਰੀ ਡਾਊਨਲੋਡਰ ਵਜੋਂ ਵਰਤੋ ਡਾਉਨਲੋਡਸ ਲਈ ਨੈੱਟਸਕੇਪ ਫਾਰਮੈਟਡ ਕੂਕੀਜ਼ ਦੀ ਵਰਤੋਂ ਕਰੋ ਅਸਥਾਈ ਫਾਈਲਾਂ ਨੂੰ ਸਾਫ਼ ਕਰੋ ਅਸਥਾਈ ਡਾਇਰੈਕਟਰੀ ਤੋਂ ਸਾਰੀਆਂ ਅਸਥਾਈ ਫਾਈਲਾਂ ਨੂੰ ਮਿਟਾਓ %1$d ਅਸਥਾਈ ਫ਼ਾਈਲ(ਫ਼ਾਈਲਾਂ) ਮਿਟਾਈਆਂ ਗਈਆਂ ਅਸਥਾਈ ਫਾਈਲਾਂ ਨੂੰ ਰੱਦ ਕੀਤੇ ਡਾਊਨਲੋਡਾਂ ਨੂੰ ਮੁੜ ਸ਼ੁਰੂ ਕਰਨ ਲਈ ਵਰਤਿਆ ਜਾ ਸਕਦਾ ਹੈ। ਕੀ ਤੁਸੀਂ ਯਕੀਨੀ ਤੌਰ \'ਤੇ ਇਹਨਾਂ ਸਾਰੀਆਂ ਫਾਈਲਾਂ ਨੂੰ ਮਿਟਾਉਣਾ ਚਾਹੁੰਦੇ ਹੋ\? \n \nਤੁਸੀਂ %1$s ਵਿੱਚ ਇਹਨਾਂ ਫ਼ਾਈਲਾਂ ਤੱਕ ਪਹੁੰਚ ਕਰ ਸਕਦੇ ਹੋ ਬਹੁ-ਚੋਣ ਮੋਡ ਗੁੰਮਨਾਮ ਡਾਊਨਲੋਡ ਇਤਿਹਾਸ ਨੂੰ ਅਸਮਰੱਥ ਬਣਾਓ ਗਤੀਸ਼ੀਲ ਰੰਗ ਵਾਲਪੇਪਰਾਂ ਤੋਂ ਐਪ ਥੀਮ \'ਤੇ ਰੰਗ ਲਾਗੂ ਕਰੋ ਮੀਟਰ ਕੀਤੇ ਨੈੱਟਵਰਕਾਂ ਨਾਲ ਕਨੈਕਟ ਹੋਣ \'ਤੇ ਮੀਡੀਆ ਨੂੰ ਡਾਊਨਲੋਡ ਕਰਨ ਦੀ ਇਜਾਜ਼ਤ ਦਿਓ ਇਹ ਫ਼ਾਈਲ ਹੁਣ ਉਪਲਬਧ ਨਹੀਂ ਹੈ ਨੈੱਟਵਰਕ ਦਰ ਸੀਮਾ ਅਧਿਕਤਮ ਡਾਊਨਲੋਡ ਸਪੀਡ ਨੂੰ ਸੀਮਤ ਕਰੋ ਨਵੀਆਂ ਕੁਕੀਜ਼ ਤਿਆਰ ਕਰੋ ਵੀਡੀਓ (ਕੋਈ ਆਡੀਓ ਨਹੀਂ) ਸੁਝਾਏ ਗਏ ਫਾਰਮੈਟ ਚੋਣ ਡਾਊਨਲੋਡ ਸ਼ੁਰੂ ਕਰਨ ਤੋਂ ਪਹਿਲਾਂ ਡਾਊਨਲੋਡ ਕਰਨ ਲਈ ਫਾਰਮੈਟ ਚੁਣੋ ਸਬ-ਡਾਇਰੈਕਟਰੀ ਵਿੱਚ ਸਹੇਜੋ ਜਦ ਉਪਲਬਧ ਹੋਣ ਤਾਂ ਸਾਫਟ ਸਬਟਾਈਟਲਾਂ ਨੂੰ ਵੀਡੀਓਜ਼ ਵਿੱਚ ਏਮਬੇਡ ਕਰੋ ਕੀ ਚੰਗੇ ਲਈ ਕਮਾਂਡ ਟੈਮਪਲੇਟਸ ਤੋਂ \"%1$s\" ਨੂੰ ਹਟਾਉਣਾ ਹੈ\? ਕਮਾਂਡ ਟੈਂਪਲੇਟਸ ਨੂੰ ਸੰਪਾਦਿਤ ਕਰੋ ਅਤੇ ਪ੍ਰਬੰਧਿਤ ਕਰੋ ਗਿਟਹੱਬ ਮੁੱਦਾ ਬੱਗ ਰਿਪੋਰਟ ਜਾਂ ਵਿਸ਼ੇਸ਼ਤਾ ਬੇਨਤੀ ਲਈ ਇੱਕ ਮੁੱਦਾ ਦਰਜ ਕਰੋ ਵੀਡੀਓ ਫਾਈਲ ਦਾ ਆਕਾਰ ਕਲਿੱਪਬੋਰਡ ਤੋਂ ਆਯਾਤ ਕਰੋ ਵੀਡੀਓ ਫਾਇਲ ਵਿੱਚ ਹਟਾਉਣ ਜਾਂ ਨਿਸ਼ਾਨ ਲਗਾਉਣ ਲਈ ਸਪਾਂਸਰਬਲਾਕ ਸ਼੍ਰੇਣੀਆਂ ਨਿਰਧਾਰਤ ਕਰੋ ਸੈਲੂਲਰ ਦੀ ਵਰਤੋਂ ਕਰਕੇ ਡਾਊਨਲੋਡ ਕਰੋ ਸੈਲੂਲਰ ਨੈੱਟਵਰਕ ਨਾਲ ਡਾਊਨਲੋਡ ਕਰਨਾ ਤੁਹਾਡੀਆਂ ਸੈਟਿੰਗਾਂ ਮੁਤਾਬਕ ਅਸਮਰੱਥ ਹੈ ਸਾਂਝੀ ਕੀਤੀ ਸਮੱਗਰੀ ਤੋਂ ਵੀਡੀਓ ਲਿੰਕ ਪੜ੍ਹ ਰਿਹਾ ਹੈ… ਹੋਰ ਕਾਰਵਾਈਆਂ ਦਿਖਾਓ ਡਾਊਨਲੋਡ ਅਧਿਸੂਚਨਾ ਆਡੀਓ ਫੋਲਡਰ ਡਾਉਨਲੋਡ ਡਾਇਰੈਕਟਰੀ ਸਾਂਝੀ ਕੀਤੀ ਸਮੱਗਰੀ ਤੋਂ URL ਨਾਲ ਮੇਲ ਕਰਨ ਵਿੱਚ ਅਸਮਰੱਥ ਡਾਊਨਲੋਡ ਕੀਤੀਆਂ ਫ਼ਾਈਲਾਂ ਅਤੇ ਪ੍ਰਗਤੀ ਬਾਰੇ ਸੂਚਿਤ ਕਰੋ ਪਲੇਲਿਸਟ ਜਾਣਕਾਰੀ ਪ੍ਰਾਪਤ ਕੀਤੀ ਜਾ ਰਹੀ ਹੈ… ਪਲੇਲਿਸਟ ਚੋਣ ਪਲੇਲਿਸਟ \"%3$s\" (%1$d ਤੋਂ %2$d ਤੱਕ) ਤੋਂ ਡਾਊਨਲੋਡ ਕਰਨ ਲਈ ਵਿਡੀਓਜ਼ ਦੀ ਰੇਂਜ ਨਿਰਧਾਰਤ ਕਰੋ। ਅੰਤ ਸ਼ੁਰੂ ਅਵੈਧ ਇੰਡੈਕਸ ਰੇਂਜ ਪਲੇਲਿਸਟ (%1$d/%2$d) ਨੂੰ ਡਾਊਨਲੋਡ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ… ਫਾਈਲਾਂ ਨੂੰ ਸੰਬੰਧਿਤ ਖੇਤਰਾਂ ਦੇ ਨਾਮ ਵਾਲੇ ਫੋਲਡਰਾਂ ਵਿੱਚ ਸੇਵ ਕਰੋ ਸਟੋਰੇਜ਼ ਇਜਾਜ਼ਤ ਸਮੱਸਿਆ ਡਾਉਨਲੋਡ/ ਅਤੇ ਦਸਤਾਵੇਜ਼/ ਤੋਂ ਬਾਹਰ ਦੀਆਂ ਡਾਇਰੈਕਟਰੀਆਂ ਸਮਰਥਿਤ ਨਹੀਂ ਹਨ ਬੈਟਰੀ ਸੰਰਚਨਾ ਇਸ ਐਪ ਨੂੰ ਬੈਕਗ੍ਰਾਊਂਡ ਵਿੱਚ ਡਾਊਨਲੋਡ ਕਰਨ ਦੇਣ ਲਈ ਬੈਟਰੀ ਔਪਟੀਮਾਈਜੇਸ਼ਨ ਨੂੰ ਅਣਡਿੱਠ ਕਰੋ ਸੀਲ ਡਾਊਨਲੋਡ ਕਰ ਰਹੀ ਹੈ… ਅਣਜਾਣ ਤਰੁੱਟੀ ਅਨੁਵਾਦ ਕਰੋ ਡਾਊਨਲੋਡ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ ਰੱਦ ਕਰ ਦਿੱਤਾ ਜਾਣਕਾਰੀ ਪ੍ਰਾਪਤ ਕੀਤੀ ਜਾ ਰਹੀ ਹੈ ਅਧਿਕਤਮ ਦਰ ਉੱਚ ਕੰਟ੍ਰਾਸਟ ਗੂੜ੍ਹਾ ਥੀਮ ਅਵੈਧ ਇਨਪੁਟ ਸਭ ਤੋਂ ਘੱਟ ਗੁਣਵੱਤਾ ਅਣਉਪਲਬਧ ਫਾਈਲ ਫਾਰਮੈਟ, ਵੀਡੀਓ ਗੁਣਵੱਤਾ, ਉਪਸਿਰਲੇਖ Yt-dlp ਸੰਸਕਰਣ, ਸੂਚਨਾ, ਪਲੇਲਿਸਟ ਦਰ ਸੀਮਾ, ਡਾਊਨਲੋਡਰ, ਕੁਕੀਜ਼ ਝਲਕ ਨੂੰ ਅਸਮਰੱਥ ਬਣਾਓ ਡਾਊਨਲੋਡ \'ਤੇ ਥੰਮਨੇਲ ਨਾਂ ਵਿਖਾਓ ਗੋਪਨੀਯਤਾ ਕਸਟਮ ਕਮਾਂਡ ਦੀ ਵਰਤੋਂ ਕਰੋ ਪਲੇਲਿਸਟ \"%1$s\" ਤੋਂ ਡਾਊਨਲੋਡ ਕਰਨ ਲਈ ਵੀਡੀਓ ਚੁਣੋ ਸਾਰੇ ਚੁਣੋ %1$d ਚੁਣਿਆ ਗਿਆ ਪ੍ਰਾਈਵੇਟ ਡਾਇਰੈਕਟਰੀ ਇੱਕ ਲੁਕੀ ਹੋਈ ਡਾਇਰੈਕਟਰੀ ਵਿੱਚ ਡਾਊਨਲੋਡ ਸਟੋਰ ਕਰੋ ਕ੍ਰੌਪ ਆਰਟਵਰਕ ਏਮਬੇਡ ਚਿੱਤਰ ਨੂੰ ਵਰਗ ਵਿੱਚ ਕੱਟੋ ਕੂਕੀਜ਼ ਦੀ ਵਰਤੋਂ ਕਰੋ ਕੀ \"%1$s\" ਲਈ ਇਸ ਐਂਟਰੀ ਨੂੰ ਹਟਾਉਣਾ ਹੈ? ਕਿਰਪਾ ਕਰਕੇ ਧਿਆਨ ਦਿਓ ਕਿ ਇਸ ਸਾਈਟ ਲਈ ਸਟੋਰ ਕੀਤੀਆਂ ਕੁੱਕੀਜ਼ ਨੂੰ ਸਾਫ਼ ਨਹੀਂ ਕੀਤਾ ਜਾਵੇਗਾ। ਇਹ ਕਿਵੇਂ ਚਲਦਾ ਹੈ\? ਟੈਲੀਗ੍ਰਾਮ ਚੈਨਲ ਕਸਟਮ ਕਮਾਂਡ ਦੀ ਵਰਤੋਂ ਕਰਦੇ ਸਮੇਂ ਕੁਝ ਵਿਕਲਪ ਉਪਲਬਧ ਨਹੀਂ ਹਨ ਕੁਝ ਸਾਈਟਾਂ ਤੋਂ ਡਾਊਨਲੋਡ ਕਰਨ ਲਈ ਖਾਤਾ ਪ੍ਰਮਾਣੀਕਰਨ ਜਾਣਕਾਰੀ ਦੀ ਲੋੜ ਹੁੰਦੀ ਹੈ। \"ਨਵੀਆਂ ਕੂਕੀਜ਼ ਤਿਆਰ ਕਰੋ\" \'ਤੇ ਕਲਿੱਕ ਕਰੋ, ਵੈੱਬਸਾਈਟ ਦਾ URL ਦਾਖਲ ਕਰੋ ਅਤੇ ਫਿਰ ਬ੍ਰਾਊਜ਼ਰ ਪੰਨੇ \'ਤੇ ਆਪਣੇ ਖਾਤੇ ਨਾਲ ਲਾਗ-ਇਨ ਕਰੋ, ਐਪ ਤੁਹਾਡੇ ਲਈ ਇਸਨੂੰ ਤਿਆਰ ਕਰੇਗੀ। ਕੁਕੀਜ਼ ਮੈਟ੍ਰਿਕਸ ਸਪੇਸ ਐਸ ਡੀ ਕਾਰਡ ਫੋਲਡਰ ਆਟੋਮੈਟਿਕ ਸੁਰਖੀਆਂ ਸਬ- ਟਾਈਟਲ ਜ਼ਿਆਦਾਤਰ ਵੀਡੀਓ ਸਟ੍ਰੀਮਿੰਗ ਪਲੇਟਫਾਰਮ ਆਡੀਓ ਅਤੇ ਵੀਡੀਓ ਨੂੰ ਵੱਖਰੇ ਤੌਰ \'ਤੇ ਪ੍ਰਦਾਨ ਕਰਦੇ ਹਨ, ਤੁਸੀਂ ਇੱਕ ਆਡੀਓ-ਓਨਲੀ ਫਾਰਮੈਟ ਨੂੰ ਚੁਣ ਸਕਦੇ ਹੋ ਅਤੇ ਇੱਕ ਵੀਡੀਓ ਵਿੱਚ ਕੇਵਲ-ਵੀਡੀਓ ਫਾਰਮੈਟ ਨੂੰ ਇੱਕ ਵੀਡੀਓ ਵਿੱਚ ਮਿਲਾ ਸਕਦੇ ਹੋ। ਸਬ- ਟਾਈਟਲ ਡਾਊਨਲੋਡ ਕਰੋ ਸਬ- ਟਾਈਟਲ ਭਾਸ਼ਾਵਾਂ ਭਾਸ਼ਾਵਾਂ, ਇੰਬੈੱਡ ਕੀਤੇ ਸਬ- ਟਾਈਟਲ, ਆਟੋ ਸੁਰਖੀਆਂ ਲੌਗ ਕਾਪੀ ਕਰੋ ਸ਼ਾਮਿਲ ਕਰੋ ਸ਼ਾਰਟਕੱਟ ਕਸਟਮ ਸ਼ਾਰਟਕੱਟ ਸੋਧੋ, ਜੋ ਕਿ ਕਮਾਂਡ ਟੈਂਪਲੇਟ ਲਿਖਣ ਲਈ ਵਰਤੇ ਜਾ ਸਕਦੇ ਹਨ। ਚੱਲ ਰਹੇ ਟਾਸਕ ਲੌਗ ਲੌਗ ਵਿਖਾਓ ਆਟੋ- ਤਿਆਰ ਕੀਤੀਆਂ ਸੁਰਖੀਆਂ ਡਾਊਨਲੋਡ ਕਰੋ ਤੁਰੰਤ ਡਾਊਨਲੋਡ ਵੀਡੀਓ ਟਾਈਟਲ ਨਮੂਨੇ ਦੀ ਲਿਖਤ ਵੀਡੀਓ ਨਿਰਮਾਤਾ ਦੀ ਨਮੂਨਾ ਲਿਖਤ ਸਾਫ ਕਰੋ ਸ਼ਾਰਟਕੱਟ ਸੋਧ ਸਪਾਂਸਰ-ਬਲਾਕ ਖੰਡਾਂ ਨੂੰ ਹਟਾਉਂਦੇ ਸਮੇਂ ਸਬ-ਟਾਈਟਲ ਗਲਤ ਸਮੇਂ ਤੇ ਹੋ ਸਕਦੇ ਹਨ। ਸਾਫ਼ਟ ਸਬਟਾਈਟਲਾਂ ਨੂੰ ਏਮਬੈਡ ਕਰਨ ਲਈ, ਵੀਡੀਓ ਨੂੰ mkv ਕੰਟੇਨਰ ਵਿੱਚ ਫਿਰ ਤੋਂ ਜੋੜਿਆ ਜਾਵੇਗਾ। ਤੁਸੀਂ VLC ਮੀਡੀਆ ਪਲੇਅਰ ਜਾਂ ਹੋਰ ਅਨੁਕੂਲ ਐਪਾਂ ਦੀ ਵਰਤੋਂ ਸਾਫ਼ਟ ਸਬਟਾਈਟਲਾਂ ਵਾਲੇ ਵੀਡੀਓ ਵੇਖਣ ਲਈ ਕਰ ਸਕਦੇ ਹੋ। ਤਰਜੀਹੀ ਆਡੀਓ ਫਾਰਮੈਟ ਅਸੀਮਤ ਸਭ ਤੋਂ ਘੱਟ ਬਿਟਰੇਟ ਆਡੀਓ ਕੁਆਲਿਟੀ ਕਈ ਕੁਆਲਿਟੀਆਂ ਮੌਜੂਦ ਹੋਣ \'ਤੇ ਆਡੀਓ ਬਿੱਟਰੇਟ ਨੂੰ ਸੀਮਤ ਕਰੋ ਫਾਰਮੈਟ ਛਾਂਟੀ yt-dlp ਦੇ -S ਵਿਕਲਪ ਨਾਲ ਫਾਰਮੈਟਾਂ ਨੂੰ ਛਾਂਟਣਾ ਆਯਾਤ ਕਰੋ ਸਿਰਲੇਖ ਨਾਮ ਬਦਲੋ ਸਕਿੰਟ ਮਿੰਟ ਸਾਂਝਾ ਕਰੋ ਸਥਿਰ ਪੂਰਵ-ਝਲਕ ਅੱਪਡੇਟ ਚੈਨਲ ਆਟੋ ਅੱਪਡੇਟ ਆਟੋ ਅੱਪਡੇਟ ਚਾਲੂ ਕਰੋ ਰੱਦ ਕਰੋ %.2f GB %.2f MB ਨਵੀਆਂ ਵਿਸ਼ੇਸ਼ਤਾਵਾਂ ਅਤੇ ਤਬਦੀਲੀਆਂ ਦੀ ਪੂਰਵ-ਝਲਕ ਦੇਖਣ ਲਈ ਪ੍ਰੀ-ਰਿਲੀਜ਼ ਬਿਲਡ ਇੰਸਟਾਲ ਕਰੋ। \n \n ਇਹਨਾਂ ਸੰਸਕਰਣਾਂ ਵਿੱਚ ਕੁਝ ਅਸਥਿਰਤਾ ਹੋਵੇਗੀ, ਇਸ ਲਈ ਜੇਕਰ ਤੁਹਾਨੂੰ ਕੋਈ ਸਮੱਸਿਆ ਆਉਂਦੀ ਹੈ ਅਤੇ ਭਵਿੱਖ ਲਈ ਐਪ ਨੂੰ ਬਿਹਤਰ ਬਣਾਉਣ ਵਿੱਚ ਸਾਡੀ ਮਦਦ ਲਈ ਕਿਰਪਾ ਕਰਕੇ ਸਾਨੂੰ ਫੀਡਬੈਕ ਦੇਣ ਵਿੱਚ ਸੰਕੋਚ ਨਾ ਕਰੋ । ਲਾਗੂ ਕਰੋ ਸਾਰੇ ਕੂਕੀਜ਼ ਨੂੰ ਸਾਫ਼ ਕਰੋ ਕਲਿੱਪ ਵੀਡੀਓ ਸ਼ੁਰੂਆਤ ਅੰਤ ਕੀ ਐਪ ਵਿੱਚ ਸਟੋਰ ਕੀਤੇ ਸਾਰੀਆਂ ਕੂਕੀਜ਼ ਨੂੰ ਮਿਟਾਉਣਾ ਹੈ\? ਅਸਥਾਈ ਫਾਈਲਾਂ ਨੂੰ ਅੰਦਰੂਨੀ ਡਾਇਰੈਕਟਰੀ ਵਿੱਚ ਸਟੋਰ ਕਰੋ ਆਡੀਓ ਫਾਰਮੈਟ ਕੋਈ ਡਾਊਨਲੋਡ ਕੀਤਾ ਮੀਡੀਆ ਨਹੀਂ ਹੈ ਬੀਟਾ ਕੀ ਪ੍ਰਯੋਗਾਤਮਕ ਵਿਸ਼ੇਸ਼ਤਾ ਨੂੰ ਸਮਰੱਥ ਬਣਾਉਣਾ ਹੈ\? ਫਾਰਮੈਟ ਚੋਣ ਪੰਨੇ ਵਿੱਚ ਵੀਡੀਓ ਕਲਿੱਪ ਬਣਾਓ ਸੀਲ ਹਮੇਸ਼ਾ ਹਰ ਕਿਸੇ ਲਈ ਮੁਫ਼ਤ ਅਤੇ ਓਪਨ ਸੋਰਸ ਹੋਵੇਗੀ। ਜੇ ਤੁਹਾਨੂੰ ਇਹ ਪਸੰਦ ਹੈ, ਤਾਂ ਕਿਰਪਾ ਕਰਕੇ ਮੈਨੂੰ GitHub \'ਤੇ ਸਪਾਂਸਰ ਕਰਨ ਬਾਰੇ ਵਿਚਾਰ ਕਰੋ! ਡਿਵੈਲਪਰ ਤੋਂ ਸੁਨੇਹਾ ਠੀਕ ਹੈ GitHub ਬਿਲਡਸ \'ਤੇ ਸਵਿੱਚ ਕਰਨਾ ਸਮਝ ਗਿਆ GitHub \'ਤੇ ਸਪਾਂਸਰ ਕਰਕੇ ਇਸ ਐਪ ਦਾ ਸਮਰਥਨ ਕਰੋ ਵੀਡੀਓ ਦੇ ਚੁਣੇ ਹੋਏ ਭਾਗਾਂ ਨੂੰ ਡਾਊਨਲੋਡ ਕਰਨ ਲਈ ਇਸ ਵਿਸ਼ੇਸ਼ਤਾ ਦੀ ਵਰਤੋਂ ਕਰਦੇ ਹੋਏ ਡਾਊਨਲੋਡਾਂ ਨੂੰ FFmpeg ਨੂੰ ਸੌਂਪਿਆ ਜਾਵੇਗਾ, ਇਹ ਵਿਸ਼ੇਸ਼ਤਾ ਅਜੇ ਵੀ ਪ੍ਰਯੋਗਾਤਮਕ ਹੈ ਅਤੇ ਕੱਟਣਾ ਪੂਰੀ ਤਰ੍ਹਾਂ ਸਹੀ ਨਹੀਂ ਹੋਵੇਗਾ, ਸਾਰੇ ਫਾਰਮੈਟ ਇਸ ਵਿਸ਼ੇਸ਼ਤਾ ਦਾ ਸਮਰਥਨ ਨਹੀਂ ਕਰਦੇ ਹਨ ਅਤੇ ਤੁਸੀਂ ਹੌਲੀ ਡਾਊਨਲੋਡ ਸਪੀਡ ਦਾ ਅਨੁਭਵ ਕਰ ਸਕਦੇ ਹੋ। %1$s ਬਿਲਡਾਂ ਲਈ ਸਵੈ-ਅੱਪਡੇਟ ਉਪਲਬਧ ਨਹੀਂ ਹੈ। ਜੇਕਰ ਤੁਸੀਂ ਆਪਣੀ ਡਿਵਾਈਸ \'ਤੇ %1$s ਨੂੰ ਸਥਾਪਿਤ ਨਹੀਂ ਕੀਤਾ ਹੈ, ਜਾਂ ਤੁਸੀਂ ਸੀਲ ਵਿੱਚ ਆਉਣ ਵਾਲੀਆਂ ਨਵੀਆਂ ਵਿਸ਼ੇਸ਼ਤਾਵਾਂ ਦਾ ਪੂਰਵਦਰਸ਼ਨ ਕਰਨਾ ਚਾਹੁੰਦੇ ਹੋ, ਤਾਂ ਕਿਰਪਾ ਕਰਕੇ %2$s \'ਤੇ ਵਿਚਾਰ ਕਰੋ। ਵਿਸ਼ੇਸ਼ਤਾ ਉਪਲਬਧ ਨਹੀਂ ਹੈ ਤੁਹਾਡਾ ਬਹੁਤ ਧੰਨਵਾਦ! ਕੋਈ ਕਸਟਮ ਕਮਾਂਡ ਕਾਰਜ ਨਹੀਂ ਪ੍ਰਾਯੋਜਕ ਸੁਝਾਅ ਪ੍ਰਾਯੋਜਿਤ ਕਰੋ ਸਬ- ਟਾਈਟਲ ਬਦਲੋ ਸਬ- ਟਾਈਟਲਾਂ ਨੂੰ ਹੋਰ ਫਾਰਮੈਟ ਵਿੱਚ ਬਦਲੋ ਵੀਡੀਓ ਨੂੰ %1$d ਚੈਪਟਰਾਂ ਵਿੱਚ ਵੰਡਿਆ ਜਾਵੇਗਾ ਵੀਡੀਓ ਵੰਡੋ ਓਹੋ! ਕੁਝ ਗਲਤ ਹੋ ਗਿਆ ਹੈ ਕਾਪੀ ਕਰੋ ਅਤੇ ਬੰਦ ਕਰੋ URL ਤੋਂ ਵੀਡੀਓ ਡਾਊਨਲੋਡ ਕਰੋ ਸ਼ੁਰੂ ਕਰੋ ਵਿਸਥਾਰ ਕਰੋ ਨਵਾਂ ਡਾਊਨਲੋਡ ਕਾਰਜ ਸੰਪਾਦਨ \"%1$s\" yt-dlp ਅੱਪਡੇਟ ਕਰੋ ਪ੍ਰੌਕਸੀ ਲੀਗੇਸੀ ਕੁਆਲਿਟੀ ਸੂਚਨਾਵਾਂ ਨੂੰ ਸਮਰੱਥ ਕਰੋ\? ਐਪ ਨੂੰ ਡਾਊਨਲੋਡ ਸਥਿਤੀ ਅਤੇ ਪ੍ਰਗਤੀ ਬਾਰੇ ਸੂਚਨਾਵਾਂ ਪੋਸਟ ਕਰਨ ਲਈ ਤੁਹਾਡੀ ਇਜਾਜ਼ਤ ਦੀ ਲੋੜ ਹੈ। ਅਸਮਰੱਥ ਕਰੋ ਡਾਇਰੈਕਟਰੀ ਸੈੱਟ ਅੱਪ ਕਰਨ ਲਈ ਟੈਪ ਕਰੋ ਕਸਟਮ ਕਮਾਂਡ ਡਾਇਰੈਕਟਰੀ ਅਸਮਰੱਥ ਕੀਤੀਆਂ ਫ਼ੋਲਡਰ ਪਿਕਰ ਹੋਰ ਐਪਾਂ ਨਾਲ ਸਾਂਝਾ ਕਰਨ ਲਈ MP4 (H.264) ਫਾਰਮੈਟਾਂ ਨੂੰ ਤਰਜੀਹ ਦਿਓ ਅਨੁਕੂਲ ਐਪਾਂ ਵਿੱਚ ਦੇਖਣ ਲਈ AV1, VP9 ਜਾਂ H.265 ਫਾਰਮੈਟਾਂ ਨੂੰ ਤਰਜੀਹ ਦਿਓ ਇੰਟਰਨੈੱਟ ਕਨੈਕਸ਼ਨਾਂ ਲਈ ਪ੍ਰੌਕਸੀ ਦੀ ਵਰਤੋਂ ਕਰੋ ਕਸਟਮ ਕਮਾਂਡਾਂ ਦੀ ਵਰਤੋਂ ਕਰਦੇ ਸਮੇਂ ਆਉਟਪੁੱਟ ਡਾਇਰੈਕਟਰੀ ਨਿਰਧਾਰਤ ਕਰੋ ਡਾਊਨਲੋਡ ਦੀ ਕਿਸਮ ਕਸਟਮ ਆਟੋ ਕਮਾਂਡਾਂ ਫਾਰਮੈਟ ਦੀ ਤਰਜੀਹ ਜਿਆਦਾ ਜਾਣੋ ਅਗਿਆਤ ਕਮਾਂਡ ਟੈਮਪਲੇਟਸ ਤੋਂ %1$s ਨੂੰ ਬਿਹਤਰੀ ਲਈ ਹਟਾਉਣਾ ਹੈ\? ਨਵੀਆਂ ਕੂਕੀਜ਼ ਬਣਾਉਣ ਵਾਸਤੇ ਵੈੱਬਪੇਜ ਖੋਲ੍ਹਣ ਲਈ ਟੈਪ ਕਰੋ: ਯੂਜ਼ਰ-ਏਜੰਟ ਸਿਰਲੇਖ %d ਆਈਟਮ %d ਆਈਟਮਾਂ ਫਾਈਲ ਵਿੱਚ ਐਕਸਪੋਰਟ ਕਰੋ yt-dlp ਵੀਡੀਓ ਡਾਊਨਲੋਡ ਕਰਨ ਲਈ ਇੱਕ ਸ਼ਕਤੀਸ਼ਾਲੀ ਕਮਾਂਡ-ਲਾਈਨ ਟੂਲ ਹੈ। ਸੀਲ ਇੱਕ ਅਨੁਭਵੀ GUI, ਆਮ ਕਮਾਂਡਾਂ ਲਈ ਪ੍ਰੀਸੈਟਸ, ਅਤੇ ਹੋਰ ਵਾਧੂ ਵਿਸ਼ੇਸ਼ਤਾਵਾਂ ਪ੍ਰਦਾਨ ਕਰਕੇ yt-dlp ਦੀ ਵਰਤੋਂ ਕਰਨਾ ਆਸਾਨ ਬਣਾਉਂਦਾ ਹੈ। \n \nyt-dlp ਦੀ ਉੱਨਤ ਵਰਤੋਂ ਲਈ, ਸੀਲ ਤੁਹਾਨੂੰ ਕਸਟਮ ਕਮਾਂਡ ਟੈਂਪਲੇਟਾਂ ਨੂੰ ਸਿੱਧਾ ਬਣਾਉਣ, ਸੁਰੱਖਿਅਤ ਕਰਨ ਅਤੇ ਚਲਾਉਣ ਦੀ ਆਗਿਆ ਦਿੰਦਾ ਹੈ, ਜਿਵੇਂ ਕਿ ਟਰਮੀਨਲ ਵਿੱਚ। \n \nਕਸਟਮ ਕਮਾਂਡਾਂ ਦੀ ਵਰਤੋਂ ਕਰਦੇ ਸਮੇਂ, ਜ਼ਿਆਦਾਤਰ GUI ਵਿਕਲਪ ਅਤੇ ਵਿਸ਼ੇਸ਼ਤਾਵਾਂ ਅਯੋਗ ਹੋ ਜਾਣਗੀਆਂ। ਡਾਊਨਲੋਡ ਆਰਕਾਇਵ ਨੂੰ ਸਾਫ਼ ਕਰੋ\? ਕੀ ਆਰਕਾਇਵ ਫਾਈਲ ਵਿੱਚੋਂ %1$s ਨੂੰ ਚੰਗੇ ਲਈ ਹਟਾਉਣਾ ਹੈ\? ਪ੍ਰੀਸੈਟਸ ਆਉਟਪੁੱਟ ਟੈਮਪਲੇਟ ਆਉਟਪੁੱਟ ਫ਼ਾਈਲ ਨਾਮ ਲਈ ਟੈਪਲੇਟ ਨਿਰਧਾਰਿਤ ਕਰੋ ਡੁਪਲੀਕੇਟ ਡਾਉਨਲੋਡਸ ਤੋਂ ਬਚਣ ਲਈ ਇੱਕ ਆਰਕਾਇਵ ਵਿੱਚ ਡਾਊਨਲੋਡ ਕੀਤੇ ਵੀਡੀਓ ਆਈ.ਡੀ. ਨੂੰ ਰਿਕਾਰਡ ਕਰੋ ਡਾਊਨਲੋਡ ਆਰਕਾਇਵ ਮੈਟਾਡੇਟਾ ਨੂੰ ਸ਼ਾਮਿਲ ਕਰੋ ਲੋੜੀਂਦਾ ਹੈ ਆਡੀਓ ਫਾਈਲ ਵਿੱਚ ਮੈਟਾਡੇਟਾ ਅਤੇ ਵੀਡੀਓ ਥੰਮਨੇਲ ਨੂੰ ਸ਼ਾਮਿਲ ਕਰੋ ਸਾਰੀਆਂ %1$d ਆਈਟਮਾਂ ਵਿਖਾਓ ਸਾਂਭੋ ਫਾਰਮੈਟ ਛਾਂਟੀ ਵਰਤੋ ਫ਼ਾਈਲ ਸੰਪਾਦਿਤ ਕਰੋ ਅਨੁਕੂਲਤਾ ਨੂੰ ਯਕੀਨੀ ਬਣਾਉਣ ਲਈ ਫਾਈਲ ਨਾਮਾਂ ਨੂੰ ਖਾਸ ਅੱਖਰਾਂ ਤੱਕ ਸੀਮਤ ਕਰੋ ਫਾਈਲਨਾਮਾਂ ਨੂੰ ਪ੍ਰਤਿਬੰਧਿਤ ਕਰੋ ਵੈਬਸਾਈਟ ਪਲੇਲਿਸਟ ਟਾਈਟਲ ਤੁਹਾਡੇ ਡਾਊਨਲੋਡ ਇਸ ਰੂਪ ਵਿੱਚ ਸਾਂਭੇ ਜਾਣਗੇ: ਸਿਸਟਮ ਸੈਟਿੰਗਜ਼ IPv4 ਨੂੰ ਫੋਰਸ ਕਰੋ ਸਾਰੇ ਕੁਨੈਕਸ਼ਨ IPv4 ਰਾਹੀਂ ਬਣਾਓ ਸਬਟਾਈਟਲ ਫਾਈਲਾਂ ਨੂੰ ਰੱਖੋ ਇਜਾਜ਼ਤ ਨਾ ਦਿਓ ਮਲਟੀਪਲ ਆਡੀਓ ਸਟ੍ਰੀਮ ਨੂੰ ਮਿਲਾਓ ਇੱਕ ਵਾਰ ਆਗਿਆ ਦਿਓ ਹਮੇਸ਼ਾ ਇਜਾਜ਼ਤ ਦਿਓ ਕੀ ਮੋਬਾਈਲ ਡਾਟਾ ਨਾਲ ਡਾਊਨਲੋਡ ਕਰਨ ਦੀ ਇਜਾਜ਼ਤ ਦੇਣੀ ਹੈ? ਮਲਟੀਪਲ ਆਡੀਓ ਸਟ੍ਰੀਮਾਂ ਨੂੰ ਇੱਕ ਸਿੰਗਲ ਫਾਈਲ ਵਿੱਚ ਵਿਲੀਨ ਕਰਨ ਦੀ ਆਗਿਆ ਦਿਓ ਖੋਜ ਕਰੋ ਡਾਊਨਲੋਡਸ ਵਿੱਚ ਖੋਜ ਕਰੋ ਸਵੈ-ਅਨੁਵਾਦਿਤ ਉਪਸਿਰਲੇਖ ਸਾਰੀਆਂ ਭਾਸ਼ਾਵਾਂ ਲਈ ਸਵੈ-ਅਨੁਵਾਦਿਤ ਉਪਸਿਰਲੇਖ ਡਾਊਨਲੋਡਾਂ ਵਿੱਚ ਉਪਲਬਧ ਹੋਣਗੇ। ਇਹ ਉਪਸਿਰਲੇਖ ਗਲਤ ਅਤੇ ਸਮਝਣ ਵਿੱਚ ਮੁਸ਼ਕਲ ਹੋ ਸਕਦੇ ਹਨ। ਆਟੋ ਫਾਰਮੈਟ ਚੋਣ ਵਿੱਚ ਡਾਉਨਲੋਡ ਕਰਨ ਲਈ ਉਪਸਿਰਲੇਖਾਂ ਦੀ ਭਾਸ਼ਾ, ਕਾਮਿਆਂ ਦੁਆਰਾ ਵੱਖ ਕੀਤੀ ਗਈ। ਅਗਲੇ ਡਾਊਨਲੋਡ ਲਈ ਯਾਦ ਰੱਖੋ ਪਿਛਲੀ ਚੋਣ ਦੀ ਵਰਤੋਂ ਕਰੋ ਕੋਈ ਵੀ ਨਹੀਂ ਰੀਸੈੱਟ ਕਰੋ ਉਪਸਿਰਲੇਖਾਂ ਵਿੱਚ ਖੋਜ ਕਰੋ ਨਹੀਂ ਧੰਨਵਾਦ ਉਪਸਿਰਲੇਖ ਭਾਸ਼ਾਵਾਂ ਅੱਪਡੇਟ ਕਰੀਏ? ਭਵਿੱਖ ਦੇ ਡਾਊਨਲੋਡਾਂ ਲਈ ਤੁਹਾਡੀ ਤਰਜੀਹ ਵਿੱਚ ਅੱਗੇ ਦਿੱਤੀਆਂ ਭਾਸ਼ਾਵਾਂ ਸ਼ਾਮਲ ਕੀਤੀਆਂ ਜਾਣਗੀਆਂ: ਐਕਸਪੋਰਟ ਕਰੋ ਫ਼ਾਈਲ ਇੰਪੋਰਟ ਕਰੋ ਪੂਰਾ ਬੈਕਅੱਪ ਬੈਕਅੱਪ ਕਿਸਮ ਇਸ ਥਾਂ ਤੇ ਐਕਸਪੋਰਟ ਕਰੋ ਕੀ ਡਾਊਨਲੋਡ ਇਤਿਹਾਸ ਇੰਪੋਰਟ ਕਰਨਾ ਹੈ? ਡਾਊਨਲੋਡ ਦਾ ਇਤਿਹਾਸ ਡਾਊਨਲੋਡ ਇਤਿਹਾਸ ਲਈ %1$s ਨੂੰ ਇੰਪੋਰਟ ਕੀਤਾ ਮੁੜ ਡਾਊਨਲੋਡ ਕਰੋ ਕਲਿੱਪਬੋਰਡ ਤੋਂ ਇੰਪੋਰਟ ਕਰੋ ਕੀ ਡਾਊਨਲੋਡ ਇਤਿਹਾਸ ਐਕਸਪੋਰਟ ਕਰਨਾ ਹੈ? ਡਾਊਨਲੋਡ ਇਤਿਹਾਸ ਤੋਂ %1$s ਨੂੰ ਐਕਸਪੋਰਟ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ। ਡਾਊਨਲੋਡ ਕੀਤੀਆਂ ਫ਼ਾਈਲਾਂ ਅਤੇ ਤਰਜੀਹਾਂ ਦਾ ਬੈਕਅੱਪ ਨਹੀਂ ਲਿਆ ਜਾਵੇਗਾ। ਡਾਊਨਲੋਡ ਕੀਤੀਆਂ ਫ਼ਾਈਲਾਂ ਨੂੰ ਇੰਪੋਰਟ ਨਹੀਂ ਕੀਤਾ ਜਾਵੇਗਾ। ਤੁਹਾਨੂੰ ਉਹਨਾਂ ਨੂੰ ਹੱਥੀਂ ਡਾਊਨਲੋਡ ਕਰਨ ਦੀ ਲੋੜ ਪਵੇਗੀ ਵੀਡੀਓ ਡਾਊਨਲੋਡ ਕਰ ਲਿਆ ਗਿਆ ਹੈ। ਜੇਕਰ ਅਜਿਹੇ ਵਿਵਹਾਰ ਉਮੀਦ ਮੁਤਾਬਿਕ ਨਹੀਂ ਹੈ, ਤਾਂ ਕਿਰਪਾ ਕਰਕੇ ਆਪਣੇ ਡਾਊਨਲੋਡ ਆਰਕਾਈਵ ਵਿੱਚ ਜਾਂਚ ਕਰੋ। Remux ਵੀਡੀਓ ਕੰਟੇਨਰ ਬਿਹਤਰ ਅਨੁਕੂਲਤਾ ਲਈ MKV ਕੰਟੇਨਰ ਵਿੱਚ ਵੀਡੀਓਜ਼ ਨੂੰ ਰੀਮਕਸ ਕਰੋ ਕੁੱਲ ਮਿਲਾ ਕੇ %2$d ਵੈੱਬਸਾਈਟਾਂ ਤੋਂ %1$d ਕੂਕੀਜ਼ ਹਰ ਰੋਜ਼ ਹਰ ਹਫ਼ਤੇ ਹਰ ਮਹੀਨੇ ਸਭ ਭਾਸ਼ਾਵਾਂ ਜਾਰੀ ਰੱਖੋ ਪ੍ਰੀਸੈੱਟ %1$s ਨੂੰ ਤਰਜੀਹ ਦਿਓ ਪ੍ਰੀਸੈਟ ਨੂੰ ਸੰਪਾਦਿਤ ਕਰੋ %d ਵੀਡੀਓ %d ਵੀਡੀਓ %d ਆਡੀਓ %d ਆਡੀਓ ਕਾਰਜ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਸ਼ਾਮਿਲ ਕੀਤਾ ਗਿਆ ਪਲੇਲਿਸਟ ਆਪਣੀ ਫਾਰਮੈਟ ਤਰਜੀਹਾਂ ਦੀ ਵਰਤੋਂ ਕਰਕੇ ਸਵੈਚਲਿਤ ਤੌਰ \'ਤੇ ਡਾਊਨਲੋਡ ਕਰੋ ਫਾਰਮੈਟਾਂ, ਉਪਸਿਰਲੇਖਾਂ ਵਿੱਚੋਂ ਚੁਣੋ, ਅਤੇ ਹੋਰ ਅਨੁਕੂਲਿਤ ਕਰੋ ਸਭ ਤੋਂ ਵਧੀਆ ਉਪਲਬਧ ਫਾਰਮੈਟ ਡਾਊਨਲੋਡ ਕਰੋ ਡਾਊਨਲੋਡ ਸ਼ੁਰੂ ਕਰਨ ਲਈ ਡਾਊਨਲੋਡ ਬਟਨ \'ਤੇ ਟੈਪ ਕਰੋ ਜਾਂ ਇਸ ਐਪ ਨੂੰ ਵੀਡੀਓ ਲਿੰਕ ਸ਼ੇਅਰ ਕਰੋ ਤੁਹਾਨੂੰ ਆਪਣੇ ਡਾਊਨਲੋਡ ਇੱਥੇ ਮਿਲਣਗੇ ਡਾਊਨਲੋਡ ਕੀਤਾ ਗਿਆ ਸਭ %1$d ਲਿੰਕਾਂ ਵਿੱਚੋਂ ਚੁਣੋ ਡਾਊਨਲੋਡ ਕਤਾਰ ਨੈਵੀਗੇਸ਼ਨ ਡਰਾਅਰ ਵਿਖਾਓ ਮਿਟਾਓ ਮੀਡੀਆ ਜਾਣਕਾਰੀ ਮੁੜ ਸ਼ੁਰੂ ਕਰੋ ਸਮੱਸਿਆ ਦਾ ਨਿਪਟਾਰਾ ਮੁੱਦਾ ਟਰੈਕਰ ਆਮ ਤਰੁੱਟੀਆਂ ਨੂੰ ਠੀਕ ਕਰੋ ਅਤੇ ਜਾਣੀਆਂ-ਪਛਾਣੀਆਂ ਸਮੱਸਿਆਵਾਂ ਦੀ ਜਾਂਚ ਕਰੋ ਇੱਕ ਤਰੁੱਟੀ ਦਾ ਸਾਹਮਣਾ ਕੀਤਾ? ਕਿਸੇ ਨਵੇਂ ਮੁੱਦੇ ਦੀ ਰਿਪੋਰਟ ਕਰਨ ਤੋਂ ਪਹਿਲਾਂ, ਕਿਰਪਾ ਕਰਕੇ ਸਾਡੇ ਮੁੱਦੇ ਟਰੈਕਰ ਨੂੰ ਖੋਜੋ। ਬਹੁਤ ਸਾਰੀਆਂ ਆਮ ਸਮੱਸਿਆਵਾਂ ਨੂੰ ਪਹਿਲਾਂ ਹੀ ਸੰਬੋਧਿਤ ਕੀਤਾ ਗਿਆ ਹੈ ਅਤੇ ਉੱਥੇ ਦਸਤਾਵੇਜ਼ੀ ਤੌਰ \'ਤੇ ਪੇਸ਼ ਕੀਤਾ ਗਿਆ ਹੈ। ਸੇਵ ਕੀਤੇ ਲਿੰਕ ਨਵਾਂ ਲਿੰਕ ਸ਼ਾਮਿਲ ਕਰੋ %1$s ਵਿੱਚ ਸ਼ਾਮਿਲ ਕਰੋ ================================================ FILE: app/src/main/res/values-pl/strings.xml ================================================ Zapisz miniaturkę Zapisz tylko audio Ustawienia Ogólne, format, niestandardowe polecenie Pobieranie Pole nie może być puste Pobierz i zapisz audio zamiast wideo Używasz najnowszej wersji yt-dlp Nie przyznano uprawnień Pobieranie „%1$s” Nie można zebrać informacji o filmie Nie można pobrać pliku Ogólne Ustaw język Usunąć\? Pobrane Otwórz link Wersja Wideo Sprawdź informacje o zmianach i nowych wersjach Zaawansowane Wyświetlacz Ciemny motyw, kolor dynamiczny, język Włączono Anuluj Ponowne kodowanie plików audio spowoduje utratę jakości dźwięku i zwiększenie rozmiaru pliku. Jakość wideo Nie sprecyzowano (domyślne) Preferowany format wideo Format wideo Konwertuj Instrukcja obsługi Pobierz wiele filmów z playlisty Pobieranie Pobieranie zakończone. Naciśnij, aby otworzyć. Adres URL filmu Zbieranie informacji o playliście… Folder audio Wybierz miejsce przechowywania plików wideo i audio Od Do Pobieranie playlisty (%1$d,%2$d)… Folder wideo Anuluj Pobieranie zakończone Język Zapisz miniaturkę jako osobny plik Nie można zainstalować najnowszej wersji yt-dlp. Upewnij się, że masz połączenie z Internetem. Zbieranie informacji o filmie… O nas Wklej adres URL ze schowka Wersja yt-dlp Naciśnij, aby zainstalować najnowszą wersję yt-dlp Potwierdź Dodatkowe ustawienia Usuń Audio Link skopiowany do schowka Usuń plik Sprawdź repozytorium Github i README Najnowsze wydanie Wersja, informacja zwrotna, automatyczna aktualizacja Podziękowania i wolne oprogramowanie Niestandardowe polecenie Wstecz Podziękowania Szablon polecenia Uruchom polecenie yt-dlp z niestandardowym szablonem Wyświetlaj szczegółowe informacje podczas pobierania Miniaturka Edytuj Wyłączono Szczegółowe informacje Ciemny motyw Domyślny systemu Skonfiguruj przed pobieraniem Wklej Dostosuj to pobieranie Konwertuj format audio Dostosuj preferencje przed pobieraniem Dokumentacja yt-dlp Konwertuj na %1$s Format Najwyższa jakość Zamknij Pobierz Nie pokazuj ponownie Otwórz ustawienia Pobierz playlistę Domyślne Powiadamiaj o pobranych plikach i postępie Powiadamiaj o pobranych plikach i postępie Uruchamianie niestandardowego polecenia… Przetłumacz Pomóż tłumaczyć tę aplikację na Hosted Weblate Nieznany błąd Nieprawidłowy zakres Następnie, po dostosowaniu opcji pobierania, naciśnij „Pobierz”. Naciśnij „Wklej”, aby dodać link do filmu ze schowka. Sprawdź i zarządzaj pobraniami w aplikacji, w tym plikami wideo i audio. Przed użyciem upewnij się, że korzystasz z najnowszej wersji yt-dlp sprawdzając ustawienia aplikacji. Sprawdzono Raport o błędach został skopiowany do schowka Wielowątkowe pobieranie Pobierz więcej części wideo M3U8/MPD równocześnie Opcje Pobieranie zostało już rozpoczęte Schowek nie zawiera poprawnego adresu URL Rozpocznij wykonywanie polecenia Folder wyjściowy i URL zostaną dodane przez aplikację. Niekonwertowany Ogranicz jakość wideo gdy występuje wiele Preferowany format, gdy dostępne jest wiele %d wątek (wątki) będzie używany do jednoczesnego pobierania natywnego wideo DASH/HLS. Nie można odczytać udostępnionego adresu URL Odczytywanie udostępnionego linku do filmu… Zakres playlisty Folder pobierania Czy chcesz na zawsze usunąć „%1$s” z historii pobierania\? W ustawieniach systemowych dla tej aplikacji ustaw „Wykorzystanie baterii” na „Bez ograniczeń”, aby pobierać w tle. Pokaż więcej akcji Powiadomienie o pobieraniu Zapisz w podfolderze Zapisz pliki w folderach o nazwach odpowiadających poszczególnym polom Problem z uprawnieniami Foldery poza Download/ i Documents/ nie są wspierane Ustawienia baterii Ignoruj optymalizację baterii dla tej aplikacji, aby pobierać w tle Seal pobiera… Określ zakres filmów z playlisty „%3$s”, które chcesz pobrać (od %1$d do %2$d). Szablon folderu wyjściowego Preferowany format audio Nieograniczony Sortowanie formatów z opcją -S dla yt-dlp Importuj minuta Usuń wszystkie ciasteczka zapisane w aplikacji na zawsze\? Wyczyść wszystkie ciasteczka Format audio Brak pobranych multimediów Beta Twórz klipy wideo na stronie wyboru formatu Obecna wersja jest aktualna Osadź napisy Etykieta Usunąć\? Wybór szablonu Wyeksportowano %1$d szablon(y) Importuj ze schowka Niedawno dodane Zaimportowano %1$d szablo(y) Kategorie SponsorBlock Automatycznie sprawdź czy dostępna jest aktualizacja na GitHub Nie udało się zaktualizować do najnowszej wersji %1$d pliki wideo, %2$d plik audio Sprawdź aktualizacje Wyłącz historię pobierania Usuń pliki tymczasowe Nieprawidłowe dane wejściowe Prywatność Użyj polecenia niestandardowego Najniższa jakość Zaznacz wszystko Katalog prywatny Przytnij osadzony obraz do kwadratu Wideo (bez audio) Sugerowany Przytnij grafikę Udostępnij Stabilna Podgląd W kolejce Zakończony Pobieranie informacji Pobieranie Otwórz plik Kopia raportu Określ kategorie SponsorBlock do usunięcia lub zaznaczenia w pliku wideo Tryb wielokrotnego wyboru Tryb prywatny Kanał Matrix Większość platform do strumieniowego przesyłania wideo dostarcza osobno audio i wideo, możesz wybrać i połączyć format audio z formatem wideo w jeden film. Wiadomość od twórcy Dziękuję bardzo! Błąd przejście na kompilacje z GitHub Ok Zrozumiano Zezwalaj na pobieranie multimediów po podłączeniu do sieci taryfowych Pobieranie przy pomocy sieci komórkowej jest wyłączone zgodnie z twoimi ustawieniami Ten plik nie jest już dostępny Zastosuj Odrzuć Koniec Początek Edytuj szablony poleceń i zarządzaj nimi Usunięto %1$d plików tymczasowych Ciasteczka Wybierz format do pobrania przed rozpoczęciem pobierania Format pliku, jakość wideo, napisy Problemy z GitHub Tryb ciemny o wysokim kontraście Nowy szablon Wybór formatu Użyj aria2c jako zewnętrzny program do pobierania Dołącz napisy do filmów, jeśli są dostępne Przerwane Brak wyświetlania miniatur podczas pobierania Niedostępne %1$d wybrane Kanał Telegram Uruchom ponownie Informacja skopiowana do schowka Limit szybkości, program do pobierania, ciasteczka Użyj ciasteczek Usunąć ten wpis dla \"%1$s\"? Należy pamiętać, że pliki cookie zapisane dla tej witryny nie zostaną usunięte. Wygeneruj nowe ciasteczka Wyłącz podgląd Przechowuj pobrane pliki w ukrytym katalogu Prześlij zgłoszenie błędu lub zaproponuj nową funkcję Dynamiczny kolor Pliki tymczasowe mogą być użyte do wznowienia przerwanych pobrań. Czy na pewno chcesz usunąć wszystkie te pliki? \n \nDostęp do tych plików można uzyskać w %1$s Pobieranie z niektórych witryn wymaga informacji uwierzytelniających konto. Kliknij „Wygeneruj nowe ciasteczka”, wprowadź adres URL witryny, a następnie zaloguj się swoim kontem na stronie przeglądarki, aplikacja wygeneruje je za Ciebie. Wersja Yt-dlp, powiadomienia, playlista Jak to działa\? Pobieranie w trakcie… Pobierania zostało anulowane Usuń \"%1$s\" z szablonów poleceń na dobre\? Rozmiar wideo Skopiuj link Rozdzielczość wideo %1$d Zadania pobierania Usunąć elementy %1$d z historii pobierania na stałe\? Użyj ciastek formatowanych jako Netscape dla pobierania Usuń wszystkie pliki tymczasowe z katalogu tymczasowego Wybierz pliki do pobrania z playlisty \"%1$s\" Klip wideo Włączyć funkcje eksperymentalne\? Funkcja niedostępna Pobieranie przy użyciu tej funkcji będzie delegowane do FFmpeg w celu pobrania wybranych fragmentów wideo, ta funkcja jest nadal w fazie eksperymentalnej, a przycinanie nie będzie całkowicie dokładne, nie wszystkie formaty obsługują tę funkcję i możesz doświadczać wolniejszych prędkości pobierania. Automatyczna aktualizacja nie jest dostępna dla %1$s kompilacji. Jeżeli nie masz %1$s zainstalowanej na telefonie lub chciałbyś zobaczyć nadchodzące nowe funkcje w Seal, rozważ %2$s. Brak niestandardowych rozkazów poleceń Eksportuj do schowka Aktualizacja Usuń lub oznacz segmenty filmów za pomocą API SponsorBlock Zastosuj kolory z tapety do motywu aplikacji Sponsor Wesprzyj aplikację poprzez wsparcie na GitHub Pobierz przy pomocy sieci komórkowej Sieć Ograniczenie prędkości Folder karty SD Niektóre opcje są niedostępna podczas korzystania z polecenia niestandardowego Ogranicz maksymalną szybkość pobierania Maksymalna prędkość Szybkie pobieranie Automatyczne napisy Pobierz automatycznie wygenerowane napisy Podtytuł Pobierz napisy Języki napisów Przykładowy tekst tytułu wideo Przykładowy tekst twórcy wideo Wyczyść Edytuj skróty Skopiuj log Języki, osadzanie napisów, napisy automatyczne Dodaj Skróty Edytuj niestandardowe skróty, które można używać do tworzenia szablonów poleceń. Jakość audio Tytuł Zmień nazwę sekunda Najniższy bitrate Przechowuj pliki tymczasowe w katalogu wewnętrznym Uruchomione zadania Pokaż logi Sponsorzy Seal zawsze będzie darmowy i z otwartym kodem źródłowym. Jeśli Ci się spodoba, rozważ wsparcie mnie na GitHub! Informacja zwrotna Podczas usuwania segmentów SponsorBlock napisy mogą wyświetlać się w niewłaściwym czasie. %.2f MB %.2f GB Automatyczna aktualizacja Włącz automatyczną aktualizację Ogranicz bitrate dźwięku, gdy występuje wiele jakości Sortowanie formatu Zainstaluj wersje przedpremierowe, aby skorzystać z nowych funkcji i zmian. \n \nW tych wersjach wystąpią pewne niestabilności, więc nie wahaj się przekazać nam opinii, jeśli napotkasz jakiekolwiek problemy, które pomogą nam ulepszyć aplikację na przyszłość. Dziennik Aby osadzić miękkie napisy, wideo będzie zremiksowane do kontenera MKV. Możesz użyć VLC Media Player lub inną zgodną aplikację do oglądania wideo z miękkimi napisami. Zaktualizuj kanał Wybieranie folderu Podziel wideo Polecenia Edytuj \"%1$s\" Nieznane Dowiedz się więcej Konwertuj napisy Typ pobierania Przestarzałe Osadzone metadane Szablony Rozpocznij Nowe zadanie pobierania Kopiuj i wyjdź Wymagane Preferencje formatu Wyłączone Wyłącz Automatyczne Rozwiń Jakość Niestandardowe Aktualizuj yt-dlp Pobierz archiwum Włączyć powiadomienia\? Serwer proxy Wyczyścić archiwum pobierania? Zapisz Użyj sortowania według formatu Szablon wyjściowy Określ szablon dla nazw plików wyjściowych Edytuj plik Tytuł listy odtwarzania Pobrane pliki zostaną zapisane jako: Preferuj formaty MP4(H.264) do udostępniania innym aplikacjom Usunąć na stałe %1$s z pliku archiwum? Strona internetowa Preferuj formaty AV1, VP9 lub H.265 do oglądania w kompatybilnych aplikacjach Ogranicz nazwy plików do określonych znaków w celu zapewnienia kompatybilności Ogranicz nazwy plików Zapisz identyfikatory pobranych filmów w archiwum w celu uniknięcia duplikatów pobrań Nanieś metadane i miniatury wideo w pliku audio Usunąć %1$s z szablonów poleceń na dobre? %d element %d elementy %d elementów %d elementów Kliknij, aby otworzyć stronę do generowania nowych plików cookie: Wideo będzie podzielone na %1$d rozdziałów Pozwól raz Pozwalaj zawsze Pozwolić na pobieranie przez sieć komórkową? Nie pozwalaj Scal wiele strumieni audio Umożliwia łączenie wielu strumieni audio w jeden plik Konwertuj napisy na inny format Ups! Coś poszło nie tak Nagłówek User-Agent Pobierz wideo z adresu URL Zachowaj pliki z napisami Użyj serwer proxy do połączenia z internetem Aplikacja potrzebuje Twojej zgody, by wyświetlać powiadomienia o statusie i postępie pobierania. Pokaż wszystkie %1$d elementów yt-dlp to potężne narzędzie wiersza poleceń do pobierania plików wideo. Seal ułatwia korzystanie z yt-dlp, zapewniając intuicyjny graficzny interfejs użytkownika, gotowe presety dla często używanych poleceń i inne dodatkowe funkcje. \n \nW celu zaawansowanego korzystania z yt-dlp Seal umożliwia tworzenie, zapisywanie i wykonywanie niestandardowych szablonów poleceń tak jak w terminalu. \n \nPodczas korzystania z niestandardowych poleceń większość opcji i funkcji GUI będzie wyłączona. Kliknij, aby wybrać katalog Określ katalogu wyjściowy podczas korzystania z poleceń niestandardowych Eksportuj do pliku Niestandardowy katalog poleceń Ustawienia systemowe Wymuś IPv4 Nawiązuj wszystkie połączenia przez IPv4 Remuksowanie kontenera wideo Remuksuj wideo do kontenera MKV dla lepszej kompatybilności Wideo zostało pobrane. Jeśli nie jest to oczekiwane zachowanie, sprawdź archiwum pobrań. Importuj z Eksportować historię pobrań? Łącznie %1$d plików cookie z %2$d witryn internetowych Automatycznie przetłumaczone napisy Języki napisów do pobrania w automatycznym wyborze formatu, oddzielone przecinkami. Pamiętaj do następnego pobrania Użyj poprzedniego wyboru Żaden Szukaj w napisach Nie, dziękuję Zaktualizować języki napisów? Eksportuj Importuj Pełna kopia zapasowa Typ kopii zapasowej Eksportuj do Plik Schowek Importować historię pobrań? Historia pobrań Wygląd i styl Automatycznie przetłumaczone napisy dla wszystkich języków będą dostępne w pobranych. Napisy te mogą być niedokładne i trudne do zrozumienia. Interfejs i interakcja Następujące języki zostaną dodane do preferowanych w przyszłych pobraniach: Pobrane pliki nie zostaną zaimportowane. Będziesz musiał pobrać je z powrotem ręcznie Eksportowanie %1$s z historii pobrań. Kopia zapasowa pobranych plików i preferencji nie zostanie utworzona. Zaimportowano %1$s do historii pobrań Codziennie Co tydzień Co miesiąc Pobierz ponownie Szukaj w pobranych Szukaj Wszystkie języki Playlista Kontynuuj Ustawienie wstępne Preferuj %1$s Pobierz automatyczne przy użyciu twoich preferencji Edytuj ustawienie wstępne Pobierz używając najlepszy dostępny format Zadanie dodane do kolejki Reset Wybieraj spośród formatów, napisów i dostosowuj dalej ================================================ FILE: app/src/main/res/values-pt/strings.xml ================================================ Procurar registos de alterações e novas versões Versão mais recente Verifique o repositório GitHub e o README Vídeo Verificado Créditos Pasta de vídeo Guardar como áudio Guardar a miniatura Definições Geral, formato, comando personalizado Descarregar A ligação não pode estar vazia Descarregar e guardar áudio, em vez de vídeo Guardar a miniatura do vídeo como um ficheiro Usando a versão mais recente do yt-dlp Não foi possível instalar a versão mais recente do yt-dlp. Certifique-se de que está ligado à Internet. Obtenção de informações sobre o vídeo… Permissão negada Descarga concluída Não foi possível descarregar o ficheiro Descarregar \"%1$s\" Uma tarefa de descarga existente já está em execução Não foi possível obter informações sobre o vídeo Geral Língua de visualização Definir a língua do ecrã Colar URL Não foi possível fazer corresponder o URL na área de transferência Versão Yt-dlp Clique para instalar a versão mais recente do yt-dlp Atualizar yt-dlp Remover\? Remover \"%1$s\" do seu histórico de descargas para sempre? Confirmar Abrir ligação Eliminar ficheiro Versão, feedback, atualização automática Cancelar Descargas Remover Acerca Áudio Ligação copiada para a área de transferência Atrás Versão Créditos e software livre Executar o comando yt-dlp com modelo personalizado Comando personalizado Modelo de comando Editar Iniciar a execução do comando Avançado Saída detalhada Imprimir mensagens detalhadas durante a descarga Ajustar esta descarga Ecrã Tema escuro, cor dinâmica, línguas Tema escuro Desligado Sistema Ligado Cancelar Configurar antes de descarregar Configurar as preferências antes de descarregar Relatório de erro copiado para a área de transferência Miniatura Colar Referências de utilização do Yt-dlp O caminho de saída e o URL serão adicionados pela aplicação. Converter formato de áudio Não convertido Formato A melhor qualidade Converter para %1$s A recodificação de ficheiros de áudio causará perda de qualidade de áudio e aumento do tamanho do ficheiro. Qualidade do vídeo Limite a qualidade do vídeo quando vários estiverem presentes Não especificado (predefinição) Formato de vídeo preferido Formato preferido quando são fornecidos vários Formato de vídeo Descarregar Fechar Não voltar a aparecer Guia do utilizador Abrir definições Clique em \"Colar\" para obter a ligação do vídeo da sua área de transferência. Em seguida, clique em \"Descarregar\" depois de ajustar as definições. Verificar e gerir as descargas na aplicação, incluindo vídeos e ficheiros de áudio. Consulte as definições de descarga e certifique-se de que tem a versão mais recente do yt-dlp antes de o utilizar. Descarregar lista de reprodução Descarregar vários vídeos de uma lista de reprodução Predefinição Descarregar Converter Notificação dos ficheiros descarregados e do seu progresso Ligação vídeo Descarga concluída. Toque para abrir. Executar comandos personalizados… Defina a utilização da bateria desta aplicação como \"Sem restrições\" nas definições do sistema para descarregar em segundo plano. Descarga multi-tarefa Descarregar mais partes de vídeos M3U8/MPD em paralelo %d thread(s) seria(m) utilizado(s) para descarregar vídeo nativo DASH/HLS em simultâneo. Opções Definições adicionais Utilizar comando personalizado Selecionar tudo %1$d selecionado(s) Vídeo (sem áudio) Texto de exemplo do criador de vídeo Legenda Limpar Cortar vídeo Fim Início Formato áudio preferido Nenhuma tarefa de comando personalizada Mensagem do programador Muito obrigado! Descarregar vídeos a partir do URL Converter legendas Converter as legendas para outro formato Ops! Algo correu mal Não é possível fazer corresponder o URL de um conteúdo partilhado Ler a ligação de vídeo de um conteúdo partilhado… Mostrar mais acções Notificação de descarga Notificação dos ficheiros descarregados e do seu progresso Obter informações sobre a lista de reprodução… Seleção da lista de reprodução Especifique o intervalo de vídeos a descarregar da lista de reprodução \"%3$s\" (de %1$d a %2$d). Terminar Intervalo de índices inválido A descarregar lista de reprodução (%1$d/%2$d)… Pasta áudio Diretório de descarga Selecionar onde guardar vídeos e ficheiros de áudio Guardar no subdiretório Guardar ficheiros em pastas com o nome dos respectivos campos Problema de permissão de armazenamento Os directórios fora de Download/ e Documents/ não são suportados Configuração da bateria Iniciar Ignorar a otimização da bateria para que esta aplicação faça descarga em segundo plano O Seal está a descarregar… Erro desconhecido Traduzir Prefixo Incorporar legendas Incorporar legendas fornecidas nos vídeos, se disponíveis Novo modelo Rótulos Remover \"%1$s\" dos modelos de comando para sempre\? Seleção do modelo Editar e gerir modelos de comandos Descarga em curso… Tarefa de descarga cancelada Exportar para a área de transferência Ajuda para traduzir esta aplicação no Hosted Weblate Remover\? Tamanho do ficheiro de vídeo Importar da área de transferência Apresentar um problema para um relatório de erros ou um pedido de funcionalidades Modelo(s) importado(s) %1$d GitHub issue Informação copiada para a área de transferência Modelo(s) exportado(s) %1$d %1$d tarefas de descarga Enfileirado Recentemente adicionados Utilizar cookies formatados pelo Netscape para descargas Limpar ficheiros temporários Eliminar todos os ficheiros temporários do diretório temporário Os ficheiros temporários podem ser utilizados para retomar as descargas canceladas. Tem a certeza de que eliminou todos estes ficheiros? \n \nPode aceder a estes ficheiros em %1$s Incógnito Entrada inválida Terminado Cookies Modo de seleção múltipla Desativar o histórico de descargas Qualidade mais baixa Eliminado %1$d ficheiro(s) temporário(s) Cor dinâmica Versão yt-dlp, notificação, lista de reprodução Limite de velocidade, descarregador, cookies Desativar a pré-visualização Armazenar descargas num diretório oculto A descarregar %1$d vídeo(s), %2$d ficheiro(s) áudio Remover %1$d item(ns) do seu histórico de descargas para sempre? Não exibir miniaturas durante a descarga Cancelado Aplicar cores dos papéis de parede ao tema da aplicação Não disponível Formato do ficheiro, qualidade do vídeo, legendas Privacidade Obtenção de informações Descarregar utilizando o telemóvel Abrir ficheiro Permitir descarga de multimédia quando ligado a redes com contador Diretório privado Cortar arte Descarregar com a rede celular está desativado de acordo com as suas definições Remover ou marcar segmentos em vídeos com a API SponsorBlock Cortar imagem incorporada num quadrado Reiniciar Este ficheiro já não está disponível Erro Copiar ligação Especificar as categorias SponsorBlock a remover ou marcar no ficheiro de vídeo Rede Selecionar vídeos a descarregar da lista de reprodução \"%1$s\" Limite de taxa Categorias de SponsorBlock Limitar velocidade máxima de descarga Copiar relatório Resolução de vídeo Verificar se há actualizações Taxa máxima Tema escuro de alto contraste Verificar automaticamente a versão mais recente no GitHub Sugerido Seleção do formato A versão atual está actualizada Seleccione o formato antes de iniciar a descarga Falha na atualização para a versão mais recente Gerar novos cookies Utilizar cookies Remover esta entrada para \"%1$s\"? Observe que os cookies armazenados neste site não serão apagados. Algumas opções não estão disponíveis quando se utiliza o comando personalizado Como funciona\? Canal do Telegram Atualização A descarga de alguns sites requer informações de autenticação da conta. Clique em \"Gerar novos cookies\", introduza o URL do sítio Web e, em seguida, inicie sessão com a sua conta na página do browser; a aplicação irá gerá-los para si. Espaço no Matrix Utilizar aria2c como descarregador externo Pasta do cartão SD Descarga rápida Legendas automáticas Descarregar legendas geradas automaticamente A maioria das plataformas de transmissão de vídeo fornece áudio e vídeo separadamente, mas pode selecionar e fundir um formato só de áudio com um formato só de vídeo num único vídeo. Título do vídeo texto de amostra Descarregar legendas Línguas das legendas Línguas, legendas incorporadas, legendas automáticas Copiar registo Editar atalhos Adicionar Atalhos Editar os atalhos personalizados que podem ser utilizados para compor modelos de comandos. Tarefas em curso Mostrar registo Registo As legendas podem ser desfasadas no tempo ao remover segmentos SponsorBlock. Para incorporar legendas, os vídeos serão remuxados para um contentor mkv. Pode utilizar o VLC Media Player ou outras aplicações compatíveis para ver vídeos com legendas incorporadas. %.2f MB Pré-lançamento Ativar a atualização automática %.2f GB Instale versões de pré-lançamento para pré-visualizar novas funcionalidades e alterações. \n \nEstas versões apresentam alguma instabilidade, pelo que não hesite em dar-nos a sua opinião se tiver algum problema para nos ajudar a melhorar a aplicação no futuro. Descartar Partilhar Canal de atualização Aplicar Ilimitado O Seal será sempre gratuito e de código aberto para todos. Se gostar, por favor considere patrocinar-me no GitHub! Taxa de bits mais baixa Qualidade do áudio Limitar a taxa de bits de áudio quando estão presentes várias qualidades Feedback Estável Atualização automática Patrocinadores Ordenação de formatos Formato de áudio Ordenar formatos com a opção -S do yt-dlp Importar Nenhuma media descarregada Título Beta Ativar a funcionalidade experimental\? Descargas que utilizem esta funcionalidade serão delegadas ao FFmpeg para descarregar secções seleccionadas do vídeo. Esta funcionalidade ainda é experimental e o corte não será totalmente exato, nem todos os formatos suportam esta funcionalidade e poderá ter velocidades de descarga mais lentas. Criar cortes de vídeo na página de seleção de formato Está bem Funcionalidade não disponível Renomear minuto A atualização automática não está disponível para %1$s compilações. Se não tiver %1$s instalado no seu dispositivo, ou se pretender pré-visualizar as novas funcionalidades do Seal, considere %2$s. segundo Entendi Limpar todos os cookies mudar para compilações do GitHub Eliminar definitivamente todos os cookies armazenados na aplicação\? Armazenar ficheiros temporários no diretório interno Dividir o vídeo Patrocinador Apoie esta aplicação patrocinando-a no GitHub O vídeo será dividido em %1$d capítulos Copiar e sair Expandir Nova tarefa de descarga Iniciar Editar \"%1$s\" Proxy Utilizar proxy para ligações à Internet Legado Qualidade Ativar as notificações\? A aplicação necessita da sua permissão para exibir notificações sobre o estado e o progresso da descarga. Toque para definir o diretório Desativado Diretório de comandos personalizado Desativar Seleção de pastas Especificar o diretório de saída quando se utilizam comandos personalizados Prefira os formatos MP4 (H.264) para partilhar com outras aplicações Prefira os formatos AV1, VP9 ou H.265 para ver em aplicações compatíveis Tipo de descarga Personalizado Automático Comandos Preferência de formato Saiba mais Desconhecido Toque para abrir a página Web para gerar novos cookies: Remover %1$s dos modelos de comando para sempre\? %d item %d itens %d item(s) Cabeçalho de requisição User-Agent Exportar para ficheiro O yt-dlp é uma poderosa ferramenta de linha de comando para descarregar vídeos. O Seal facilita o uso do yt-dlp fornecendo uma GUI intuitiva, predefinições para comandos comuns e outros recursos adicionais. \n \nPara uma utilização avançada do yt-dlp, o Seal permite-lhe criar, guardar e executar modelos de comandos personalizados diretamente, tal como num terminal. \n \nAo usar comandos personalizados, a maioria das opções e recursos da GUI serão desativados. Predefinições Modelo de saída Especificar o modelo para os nomes dos ficheiros de saída Limpar arquivo de descarga? Remover %1$s do arquivo de descarga? Registar IDs de vídeos descarregados em um arquivo para evitar descargas duplicadas Arquivo de descarga Incorporar metadados Incorporar metadados e miniatura de vídeo no ficheiro de áudio Obrigatório Mostrar todos os %1$d itens Guardar Utilizar a ordenação de formatos Editar ficheiro Limitar os nomes de ficheiros a caracteres específicos para garantir a compatibilidade Restringir nomes de ficheiros Sítio Título da lista de reprodução As suas descargas serão guardadas como: Definições do sistema Forçar IPv4 Efetuar todas as ligações através de IPv4 Manter ficheiros de legendas Permitir uma vez Permitir sempre Não permitir Permitir a descarga com telemóvel? Unir vários fluxos de áudio Permitir a fusão de vários fluxos de áudio num único ficheiro Pesquisar Pesquisar em descargas Legendas traduzidas automaticamente Lembrar para a próxima descarga Utilizar a seleção anterior Nenhum Idioma das legendas a descarregar na seleção de formato automático, separado por vírgulas. As legendas com tradução automática para todos os idiomas estarão disponíveis nas descargas. Estas legendas podem ser inexactas e difíceis de compreender. Repor Pesquisar em legendas Não obrigado Os seguintes idiomas serão adicionados às suas preferências para futuras descargas: Atualizar idiomas das legendas? Importar o histórico de descargas? Exportar Importar Backup completo Tipo de Backup Ficheiro Área de transferência Importar de Os ficheiros descarregados não serão importados. Terá de os voltar a descarregar manualmente Histórico de descargas Exportar para Exportar o histórico de descargas? A exportar %1$s do histórico de descargas. Os ficheiros descarregados e as preferências não serão guardados. Importou %1$s para o histórico de descargas Descarregar novamente O vídeo foi descarregado. Se este não for o comportamento esperado, verifique o seu arquivo de descarga. Recipiente de vídeo Remux Remuxar vídeos para um recipiente MKV para uma melhor compatibilidade %1$d cookies de %2$d sites no total Diariamente Semanalmente Mensalmente Todos os idiomas Predefinição Escolha entre formatos, legendas e personalize ainda mais Descarregar automaticamente utilizando as suas preferências de formato Descarregar o melhor formato disponível Editar predefinição %d vídeo %d vídeos %d vídeos %d áudio %d áudios %d áudios Lista de reprodução Continuar Preferir %1$s Tarefa adicionada à fila Encontrará as suas descargas aqui Toque no botão de descarga ou partilhe uma ligação de vídeo para esta aplicação para iniciar uma descarga Descarregado Tudo Selecionar a partir de %1$d ligações Fila de descargas Mostrar gaveta de navegação Retomar Eliminar Informações da mídia Encontrou um erro? Antes de comunicar um novo problema, procure no nosso registo de problemas. Muitos problemas comuns já foram resolvidos e documentados lá. Solução de problemas Rastreador de problemas Correção de erros comuns e verificação de problemas conhecidos Links salvos Adicionar novo link Adicionar a %1$s ================================================ FILE: app/src/main/res/values-pt-rBR/strings.xml ================================================ Pasta de Vídeo Salvar miniatura Geral, formato, comando personalizado Download O link não pode estar vazio Baixar e salvar o áudio ao invés do vídeo Salvar miniatura do vídeo como um arquivo Usando a versão mais recente do yt-dlp Buscando informações do vídeo… Permissão negada O arquivo não pôde ser baixado Baixar “%1$s” As informações do vídeo não foram encontradas Geral Salvar como áudio Configurações A última versão do yt-dlp não pôde ser instalada. Por favor verifique sua conexão com a internet. Download concluído Colar URL Versão do Yt-dlp Remover\? Remover “%1$s” do seu histórico de downloads definitivamente\? Confirmar Downloads Áudio Link copiado para a área de transferência Abrir link Remover Excluir arquivo Sobre Versão Versão mais recente Verifique o repositório no GitHub e o README Vídeo Créditos Créditos e software livre Comando Personalizado Mostrar mensagens detalhadas ao fazer download Tema, cores, idiomas Tema escuro Sistema Ajustar este download Relatório de erros copiado para a área de transferência Colar Referências de uso do yt-dlp Converter formato de áudio Não convertido Converter para %1$s Formato Melhor qualidade Limitar a qualidade do vídeo quando outras qualidades estiverem presentes Não especificado (padrão) Formato de vídeo preferido Download Configurar antes de fazer o download Clique para instalar a versão mais recente do yt-dlp Versão, feedback, atualização Configurar preferências antes de fazer o download Converter Não foi possível corresponder ao URL na área de transferência Uma tarefa de download existente já está em execução Cancelar Formato de vídeo Procurar registros de alterações e novas versões O caminho de saída e a URL serão adicionados pelo aplicativo. A recodificação de arquivos de áudio causará perda na qualidade do áudio e aumento no tamanho do arquivo. Qualidade do vídeo Formato preferido quando outros formatos estiverem presentes Idioma de exibição Definir idioma de exibição Não mostrar novamente Guia do usuário Abrir configurações Clique em “Colar” para obter o link do vídeo da sua área de transferência. Baixar playlist Baixe vários vídeos de uma playlist Padrão Download Notificar sobre o download e arquivos baixados Link do vídeo Download concluído. Toque para abrir. Executando comandos personalizados… Fragmentos Simultâneos Opções Lendo o link do vídeo do conteúdo compartilhado… Mostrar mais ações Notificar sobre o download e arquivos baixados Buscando informações da playlist… Especifique o intervalo de vídeos para download da playlist “%3$s” (de %1$d a %2$d). Início Fim Intervalo de índice inválido Fazendo o download da playlist (%1$d/%2$d)… Pasta de Áudio Pasta de Download Selecione onde armazenar vídeos e arquivos de áudio Salvar no subdiretório Salve arquivos em pastas nomeadas como respectivos campos Configuração da bateria Verifique e gerencie downloads no aplicativo, incluindo vídeos e arquivos de áudio. Em seguida, clique em “Download” depois de ajustar suas configurações. Dê uma olhada nas configurações de download e verifique se você tem a versão mais recente do yt-dlp antes de usá-lo. Número de fragmentos de um vídeo nativo em M3U8/MPD para baixar paralelamente Diretórios fora de Download/ e Documents/ não são suportados Por favor, defina o uso da bateria deste aplicativo para “Irrestrito” nas configurações do sistema para fazer download em segundo plano. Configurações adicionais Não foi possível corresponder o URL do conteúdo compartilhado %d fragmento(s) seriam usados para baixar vídeo nativo DASH/HLS simultaneamente. Problema de permissão de armazenamento Traduzir Ignorar \"Otimização da Bateria\" para este aplicativo fazer download em segundo plano Erro desconhecido Ajude a traduzir este aplicativo em seu idioma usando Weblate Voltar Verificado Executar o comando yt-dlp com um modelo personalizado Modelo de comando Editar Iniciar a execução do comando Avançado Saída detalhada Exibição Ativado Desativado Cancelar Miniatura Fechar Notificação de download Seleção de playlist Seal está fazendo o download… Prefixo Abrir arquivo Reiniciar Fazendo o download Erro Copiar link Copiar informação Buscando informações Enfileirado Cancelado Concluído Adicionar legendas Adicionar legendas em vídeos, se disponíveis Novo modelo Título Remover\? Remover %1$s dos modelos de comandos? Seleção de modelos Editar e gerenciar modelos de comandos Download em andamento… Tarefa de download cancelada Relatórios do GitHub Envie um problema para relatório de bug ou solicitação de recursos Informação copiada para a área de transferência Resolução do vídeo Tamanho do arquivo de vídeo Importar da área de transferência %1$d modelo(s) importado(s) %1$d tarefa(s) de download(s) Adicionado recentemente %1$d vídeo(s), %2$d arquivo(s) de áudio Remover %1$d item(s) do seu histórico de downloads\? Exportado %1$d modelo(s) Exportar para a área de transferência Buscar atualizações Verificar automaticamente a versão mais recente no GitHub Você já possui a atualização mais recente Falha ao atualizar para a versão mais recente Atualizar Usar aria2c como provedor de download padrão Usar cookies formatados do Netscape para download Remover arquivos temporários Excluir todos os arquivos temporários do diretório temporário %1$d arquivo(s) temporário(s) removido(s) Modo multi-seleção Arquivos temporários podem ser usados para retomar downloads cancelados. Tem certeza de que deseja excluir todos esses arquivos\? \n \nVocê pode acessar esses arquivos em %1$s Especifique as categorias SponsorBlock para remover ou marcar no arquivo de vídeo Categorias do SponsorBlock Remover ou marcar segmentos em vídeos com a API SponsorBlock Tema escuro de alto contraste Entrada inválida Menor qualidade Anônimo Faça o download sem adicionar no histórico Cor dinâmica Aplicar cores do papel de parede ao tema do aplicativo Esse arquivo não está mais disponível Rede Limite de conexão Limitar velocidade máxima de download Taxa máxima Indisponível Formato, qualidade, legendas Versão do yt-dlp, notificação, playlist Desativar pré-visualização Desativar pré-visualização em downloads Privacidade Limite de conexão, provedor de download, cookies Diretório privado Armazenar downloads em um diretório oculto Recortar miniatura em um quadrado Cortar miniatura Permite fazer o download quando conectado na rede móvel O download com a rede móvel está desabilitado de acordo com suas configurações Usar comando personalizado Fazer download usando a rede móvel Selecionar tudo Selecionar vídeos para fazer o download da playlist “%1$s” %1$d selecionado(s) Ilimitado Taxa de bits mais baixa Classificando formatos com a opção -S de yt-dlp Importar Título Renomear segundo minuto Remover todos os cookies Excluir todos os cookies armazenados no aplicativo\? %.2f MB %.2f GB Seleção de formato Gerar novos cookies Vídeo (sem áudio) Selecione o formato a ser baixado antes de iniciar o download As legendas podem ser mal cronometradas ao remover segmentos do SponsorBlock. Compartilhar Estável Pré-lançamento Instale compilações de pré-lançamento para visualizar novos recursos e alterações. \n \nHaverá alguma instabilidade nessas versões, portanto, não hesite em nos enviar feedback se tiver algum problema para nos ajudar a melhorar o aplicativo para o futuro. Canal de atualização Atualização automática Ativar atualização automática Log Espaço no Matrix A maioria das plataformas de streaming de vídeo oferece áudio e vídeo separadamente, você pode selecionar e mesclar um formato somente de áudio com um formato somente de vídeo em um único vídeo. Descartar Aplicar Cortar vídeo Início Fim Sugerido Como funciona\? Usar cookies Cookies Remover esta entrada para \"%1$s\"? Observe que os cookies armazenados neste site não serão apagados. Algumas opções não estão disponíveis ao usar o comando personalizado Canal do Telegram Pasta do cartão SD Legendas automáticas Baixar legendas geradas automaticamente Download rápido Texto de amostra do título do vídeo Texto de amostra do criador de vídeo Legenda Baixar legenda Idiomas das legendas Copiar log Limpar Adicionar Atalhos Tarefas em execução Mostrar log Para incorporar legendas, os vídeos serão remixados no contêiner mkv. Você pode usar o VLC Media Player ou outros aplicativos compatíveis para assistir a vídeos com legendas incorporadas. O download de alguns sites requer informações de autenticação de conta. Clique em \"Gerar novos cookies\", digite o URL do site e faça login com sua conta na página do navegador, o aplicativo irá gerá-lo para você. Idiomas, legendas incorporadas, legendas automáticas Editar atalhos Edite os atalhos personalizados que podem ser usados para compor modelos de comando. Limite a taxa de bits de áudio quando várias qualidades estiverem presentes Formato de áudio preferido Qualidade do áudio Classificação de formatos Patrocinadores Apoie este aplicativo patrocinando no GitHub Patrocinar Armazenar arquivos temporários no diretório interno Feedback Seal sempre será livre e de código aberto para todos. Se você gostou, por favor, considere me patrocinar no GitHub! Formato de áudio Nenhuma mídia baixada Beta Ativar recurso experimental\? Faça cortes nos vídeos na página de seleção de formato Downloads usando este recurso serão delegados ao FFmpeg para baixar seções selecionadas do vídeo, este recurso ainda é experimental e o corte não será totalmente preciso, nem todos os formatos. Mudando para versões no GitHub Certo Entendi Recurso não disponível A atualização automática não está disponível para as versões do %1$s. Se você não tem o %1$s instalado em seu dispositivo, ou gostaria de visualizar novos recursos em desenvolvimento no aplicativo Seal, por favor, considere %2$s. Muito obrigado! Mensagem do desenvolvedor Converter legendas Converter legendas para outro formato Dividir vídeo O vídeo será dividido em %1$d capítulos Oops! Algo deu errado Copiar e sair Nenhuma tarefa de comando personalizada Fazer download dos vídeos do URL Nova tarefa de download Editar \"%1$s\" Expandir Iniciar Atualizar yt-dlp Usar proxy para conexões de internet Desativar Toque para configurar o diretório Pasta de comando personalizado Desabilitado Selecionar pasta Especifique o diretório de saída ao usar comandos personalizados Preferir formatos AV1, VP9 ou H.265 para assistir em aplicativos compatíveis Legado Melhor formato Ativar notificações\? O aplicativo precisa da sua permissão para postar notificações sobre o status e o progresso do download. Preferir formatos MP4(H.264) para compartilhar com outros aplicativos Proxy Tipo de download Personalizado Comando Preferência de formato Saber mais Desconhecido Automático Toque para abrir a página da web para gerar novos cookies: Remover %1$s dos modelos de comando\? %d item %d itens %d itens Cabeçalho de requisição User-Agent Exportar como arquivo O yt-dlp é uma poderosa ferramenta de linha de comando para fazer download de vídeos. Seal facilita o uso do yt-dlp, fornecendo uma GUI intuitiva, predefinições para comandos comuns e outros recursos adicionais. \n \nPara uso avançado do yt-dlp, o Seal permite criar, salvar e executar modelos de comandos personalizados diretamente, como em um terminal. \n \nAo usar comandos personalizados, a maioria das opções e recursos da GUI seriam desabilitados. Predefinições Modelo de saída Especificar o modelo para nomes de arquivos de saída Limpar arquivamento de download? Remover %1$s do arquivamento de download? Salvar IDs dos vídeos baixados para evitar downloads duplicados Arquivar downloads Incorporar metadados Incorporar metadados e miniaturas de vídeo no arquivo de áudio Obrigatório Mostrar todos os %1$d itens Salvar Usar classificação de formato Editar arquivo Limite os nomes dos arquivos a caracteres específicos para garantir a compatibilidade Restringir nomes de arquivos Site Título da playlist Seus downloads serão salvos como: Configurações do sistema Forçar IPv4 Efetuar todas as conexões com IPv4 Manter arquivos de legenda Permitir uma vez Mesclar vários fluxos de áudio Sempre permitir Não permitir Permitir download através da rede móvel? Permitir que vários fluxos de áudio sejam mesclados em um único arquivo Pesquisar em downloads Pesquisar Lembrar próximo download Usar seleção anterior Nenhum Veja & Sinta Legendas traduzidas automaticamente para todos os idiomas estarão disponíveis para download. Essas legendas podem ser imprecisas e difíceis de entender. Idioma das legendas para download em seleção automática de formato, separadas por vírgulas. Legendas traduzidas automaticamente Reiniciar Pesquisar nas legendas Não, obrigado Atualizar idiomas das legendas? Os seguintes idiomas serão adicionados à sua preferência para downloads futuros: Exportar para Tipo de Backup Interface & Interação Exportar Importar Backup completo Arquivo Área de transferência Importar de Exportar histórico de downloads? Os arquivos baixados não serão importados. Você precisará baixá-los de volta manualmente Histórico de downloads Importar histórico de downloads? Exportando %1$s do histórico de downloads. Os arquivos baixados e as preferências não serão exportados. Baixar novamente Importou %1$s para o histórico de downloads O vídeo foi baixado. Se este não for o comportamento esperado, verifique o arquivamento de download. Contêiner de vídeo Remux Remuxar vídeos em contêiner MKV para melhor compatibilidade %1$d cookies de %2$d sites no total Semanalmente Mensalmente Diariamente Todos os idiomas Continuar Playlist Editar predefinição Faça o download do melhor formato disponível Predefinição Preferir %1$s Escolha entre formatos, legendas e personalize ainda mais Faça o download automaticamente usando suas preferências de formato %d vídeo %d vídeos %d vídeos %d áudio %d áudios %d áudios Tarefa adicionada à fila Você encontrará seus downloads aqui Toque no botão de download ou compartilhe um link de vídeo com este aplicativo para iniciar o download Download concluído Todos Fila de downloads Selecionar entre %1$d links Mostrar menu de navegação Excluir Continuar Informações da mídia Solução de problemas Corrigir erros comuns e verificar se há problemas conhecidos Encontrou um erro? Antes de informar um novo problema, pesquise em nosso rastreador de problemas. Muitos problemas comuns já foram resolvidos e documentados lá. Rastreador de problemas ================================================ FILE: app/src/main/res/values-pt-rPT/strings.xml ================================================ Eliminar ficheiro Modelo(s) importado(s) %1$d Resolução de vídeo mudar para compilações do GitHub Selecionar onde guardar vídeos e ficheiros de áudio Guardar no subdiretório Cancelar Diretório de transferência O yt-dlp é uma poderosa ferramenta de linha de comando para descarregar vídeos. O Seal facilita o uso do yt-dlp fornecendo uma GUI intuitiva, predefinições para comandos comuns e outros recursos adicionais. \n \nPara uma utilização avançada do yt-dlp, o Seal permite-lhe criar, guardar e executar modelos de comandos personalizados diretamente, tal como num terminal. \n \nAo usar comandos personalizados, a maioria das opções e recursos da GUI serão desativados. Enfileirado Avançado Limpar o arquivo de download? Início Taxa máxima Incorporar legendas fornecidas nos vídeos, se disponíveis Fechar Aplicar cores dos papéis de parede ao tema da aplicação Limitar velocidade máxima de descarga Guardar Saída detalhada Guia do utilizador Registo O vídeo será dividido em %1$d capítulos Remover esta entrada para \"%1$s\"? Observe que os cookies armazenados neste site não serão apagados. Qualidade do áudio Entrada inválida Formato de áudio Remover %1$d item(ns) do seu histórico de transferências para sempre? Título Formato de vídeo preferido Utilizar cookies Exportar para ficheiro Estável A aplicação necessita da sua permissão para publicar notificações sobre o estado e o progresso da transferência. Seleção de pastas Nenhuma media descarregada Modo de seleção múltipla Ligação copiada para a área de transferência Instale versões de pré-lançamento para pré-visualizar novas funcionalidades e alterações. \n \nEstas versões apresentam alguma instabilidade, pelo que não hesite em dar-nos a sua opinião se tiver algum problema para nos ajudar a melhorar a aplicação no futuro. As suas transferências serão guardadas como: Editar os atalhos personalizados que podem ser utilizados para compor modelos de comandos. Vídeo (sem áudio) Dividir o vídeo Créditos e software livre Prefira os formatos MP4 (H.264) para partilhar com outras aplicações Descarregar vídeos a partir do URL Comandos Verifique o repositório GitHub e o README Tamanho do ficheiro de vídeo Desativar a pré-visualização Iniciar Os downloads que utilizem esta funcionalidade serão delegados ao FFmpeg para descarregar secções seleccionadas do vídeo. Esta funcionalidade ainda é experimental e o corte não será totalmente exato, nem todos os formatos suportam esta funcionalidade e poderá ter velocidades de download mais lentas. Remover \"%1$s\" dos modelos de comando para sempre? Algumas opções não estão disponíveis quando se utiliza o comando personalizado Remover %1$s do arquivo de download? Novo modelo Remover O descarregamento com a rede celular está desativado de acordo com as suas definições Incorporar legendas Não apresentação de miniaturas durante a transferência Seleccione o formato a transferir antes de iniciar a transferência Fim Editar \"%1$s\" Remover? Sítio Descarregar legendas geradas automaticamente %1$d selecionado(s) Executar o comando yt-dlp com modelo personalizado Não disponível Clique para instalar a versão mais recente do yt-dlp Sugerido Tema escuro de alto contraste Seleção do modelo Obter informações sobre a lista de reprodução… Consulte as definições de transferência e certifique-se de que tem a versão mais recente do yt-dlp antes de o utilizar. Traduzir Descarregar e guardar áudio, em vez de vídeo Guardar ficheiros em pastas com o nome dos respectivos campos Utilizar cookies formatados pelo Netscape para transferências Copiar relatório Limpar Desconhecido Não foi possível fazer corresponder o URL na área de transferência Descarregar legendas Qualidade mais baixa Saiba mais Formato preferido quando são fornecidos vários Descarregar lista de reprodução Clique em \"Colar\" para obter a ligação do vídeo da sua área de transferência. Versão yt-dlp, notificação, lista de reprodução Renomear Converter legendas Utilizar aria2c como descarregador externo Prefira os formatos AV1, VP9 ou H.265 para ver em aplicações compatíveis Descarregar utilizando o telemóvel Copiar registo Ligado Geral, formato, comando personalizado Referências de utilização do Yt-dlp Editar Tipo de transferência Colar URL Aplicar Legado Criar cortes de vídeo na página de seleção de formato Converter Ops! Algo correu mal Atualização Qualidade do vídeo Ilimitado Ligação vídeo Editar atalhos Tema escuro, cor dinâmica, línguas Configurar as preferências antes de descarregar Procurar registos de alterações e novas versões Como funciona? Entendi Os directórios fora de Download/ e Documents/ não são suportados Notificação dos ficheiros descarregados e do seu progresso Incorporar metadados Descarregamento em curso… Diretório de comandos personalizado Versão Yt-dlp Seleção do formato Ecrã Especificar o diretório de saída quando se utilizam comandos personalizados Utilizar a ordenação de formatos Feedback Cookies Problema de permissão de armazenamento Desligado Limite de velocidade, descarregador, cookies Beta Notificação de descarregamento Prefixo Limpar todos os cookies Descarregar Predefinições segundo Relatório de erro copiado para a área de transferência Toque para definir o diretório Canal de atualização Atrás Transferências Versão mais recente Em seguida, clique em \"Descarregar\" depois de ajustar as definições. Pasta áudio O Seal será sempre gratuito e de código aberto para todos. Se gostar, por favor considere patrocinar-me no GitHub! Cortar arte As legendas podem ser desfasadas no tempo ao remover segmentos SponsorBlock. Iniciar Terminar %.2f GB Muito obrigado! Descarregar tarefa cancelada Apoie esta aplicação patrocinando-a no GitHub Versão Não foi possível instalar a versão mais recente do yt-dlp. Certifique-se de que está ligado à Internet. Ativar a funcionalidade experimental? Tarefas em curso Geral O Seal está a descarregar… Cabeçalho de requisição User-Agent Mostrar registo Imprimir mensagens detalhadas durante o descarregamento Limitar os nomes de ficheiros a caracteres específicos para garantir a compatibilidade Permitir o descarregamento de multimédia quando ligado a redes com contador Descartar Pasta do cartão SD Abrir definições Verificar e gerir as transferências na aplicação, incluindo vídeos e ficheiros de áudio. Descarga concluída Formato áudio preferido Nova tarefa de transferência Copiar e sair Os ficheiros temporários podem ser utilizados para retomar as transferências canceladas. Tem a certeza de que eliminou todos estes ficheiros? \n \nPode aceder a estes ficheiros em %1$s Formato de vídeo Armazenar transferências num diretório oculto Converter formato de áudio Definições adicionais Editar e gerir modelos de comandos %.2f MB Pasta de vídeo Selecionar tudo A ligação não pode estar vazia Limite a qualidade do vídeo quando vários estiverem presentes Colar Rótulos Remover \"%1$s\" do seu histórico de transferências para sempre? Descarregar Transferir vários vídeos de uma lista de reprodução Guardar a miniatura do vídeo como um ficheiro Erro desconhecido Rede Ordenar formatos com a opção -S do yt-dlp Atualização automática Informação copiada para a área de transferência Especificar as categorias SponsorBlock a remover ou marcar no ficheiro de vídeo Iniciar a execução do comando Verificado Acerca Abrir ligação Reiniciar Opções Patrocinador A recodificação de ficheiros de áudio causará perda de qualidade de áudio e aumento do tamanho do ficheiro. Não convertido Legendas automáticas Obrigatório Cor dinâmica Para incorporar legendas, os vídeos serão remuxados para um contentor mkv. Pode utilizar o VLC Media Player ou outras aplicações compatíveis para ver vídeos com legendas incorporadas. A maioria das plataformas de transmissão de vídeo fornece áudio e vídeo separadamente, mas pode selecionar e fundir um formato só de áudio com um formato só de vídeo num único vídeo. Nenhuma tarefa de comando personalizada Não é possível fazer corresponder o URL de um conteúdo partilhado Ativar a atualização automática Versão, feedback, atualização automática Obtenção de informações Taxa de bits mais baixa Miniatura Descarregamento multi-tarefa Não foi possível descarregar o ficheiro Modelo de comando Descarregar mais partes de vídeos M3U8/MPD em paralelo Descarregar \"%1$s\" Preferência de formato Pré-lançamento Limpar ficheiros temporários Remover ou marcar segmentos em vídeos com a API SponsorBlock Apresentar um problema para um relatório de erros ou um pedido de funcionalidades Tema escuro Cancelar Eliminado %1$d ficheiro(s) temporário(s) Converter as legendas para outro formato Verificar se há actualizações Mostrar mais acções Mostrar todos os %1$d itens Falha na atualização para a versão mais recente Eliminar todos os ficheiros temporários do diretório temporário Título do vídeo texto de amostra Utilizar comando personalizado Não voltar a aparecer Configurar antes de descarregar Verificar automaticamente a versão mais recente no GitHub Este ficheiro já não está disponível Modelo de saída Especificar o modelo para os nomes dos ficheiros de saída Línguas das legendas Modelo(s) exportado(s) %1$d Desativar o histórico de transferências Confirmar Permissão negada Definir a língua do ecrã Limitar a taxa de bits de áudio quando estão presentes várias qualidades Sistema Desativado Diretório privado Desativar Remover? Língua de visualização Está bem Seleção da lista de reprodução Transferência concluída. Toque em para abrir. Automático Atalhos Selecionar vídeos a transferir da lista de reprodução \"%1$s\" %d item %d itens %d item(s) Terminado Comando personalizado Já está a decorrer uma tarefa de descarregamento existente Ajustar esta transferência Vídeo Abrir ficheiro Texto de exemplo do criador de vídeo Ajuda para traduzir esta aplicação no Hosted Weblate Expandir Não foi possível obter informações sobre o vídeo Recentemente adicionados Canal do Telegram Intervalo de índices inválido Descarregar Funcionalidade não disponível Armazenar ficheiros temporários no diretório interno Definições do sistema A atualização automática não está disponível para %1$s compilações. Se não tiver %1$s instalado no seu dispositivo, ou se pretender pré-visualizar as novas funcionalidades do Seal, considere %2$s. O descarregamento de alguns sites requer informações de autenticação da conta. Clique em \"Gerar novos cookies\", introduza o URL do sítio Web e, em seguida, inicie sessão com a sua conta na página do browser; a aplicação irá gerá-los para si. Utilizar proxy para ligações à Internet Gerar novos cookies Qualidade Defina a utilização da bateria desta aplicação como \"Sem restrições\" nas definições do sistema para descarregar em segundo plano. Toque para abrir a página Web para gerar novos cookies: Não especificado (predefinição) A descarregar Definições Usando a versão mais recente do yt-dlp Erro A versão atual está actualizada Espaço no Matrix Guardar como áudio Categorias de SponsorBlock Legenda %1$d Tarefas de transferência GitHub issue Mensagem do programador Exportar para a área de transferência Guardar a miniatura Importar Cortar vídeo Especifique o intervalo de vídeos a descarregar da lista de reprodução \"%3$s\" (de %1$d a %2$d). Notificação dos ficheiros descarregados e do seu progresso Restringir nomes de ficheiros Partilhar %d thread(s) seria(m) utilizado(s) para descarregar vídeo nativo DASH/HLS em simultâneo. Importar da área de transferência Adicionar Ignorar a otimização da bateria para que esta aplicação seja transferida em segundo plano Executar comandos personalizados… Converter para %1$s Privacidade Predefinição Editar ficheiro Registar IDs de vídeos descarregados num arquivo para evitar descargas duplicadas Formato Personalizado Atualizar yt-dlp Transferência rápida Incógnito Incorporar metadados e miniatura de vídeo no ficheiro de áudio Cortar imagem incorporada num quadrado Título da lista de reprodução Áudio Arquivo de download O caminho de saída e o URL serão adicionados pela aplicação. Línguas, legendas incorporadas, legendas automáticas Cancelado Configuração da bateria Ler a ligação de vídeo de um conteúdo partilhado… Remover %1$s dos modelos de comando para sempre? Ativar as notificações? Proxy %1$d vídeo(s), %2$d ficheiro(s) áudio Formato do ficheiro, qualidade do vídeo, legendas Patrocinadores Créditos Copiar ligação Obtenção de informações sobre o vídeo… Eliminar definitivamente todos os cookies armazenados na aplicação? A melhor qualidade minuto A descarregar lista de reprodução (%1$d/%2$d)… Ordenação de formatos Limite de taxa Forçar IPv4 Efetuar todas as ligações através de IPv4 Manter ficheiros de legendas Permitir uma vez Permitir sempre Permitir a descarga com telemóvel? Unir vários fluxos de áudio Não permitir Permitir a fusão de vários fluxos de áudio num único ficheiro Pesquisar em descargas Pesquisar Legendas traduzidas automaticamente As legendas com tradução automática para todos os idiomas estarão disponíveis nas descargas. Estas legendas podem ser inexactas e difíceis de compreender. Idioma das legendas a descarregar na seleção de formato automático, separado por vírgulas. Aspeto & Sentimento Utilizar a seleção anterior Nenhum Lembrar para a próxima descarga Repor Não obrigado Pesquisar em legendas Os seguintes idiomas serão adicionados às suas preferências para futuras descargas: Atualizar idiomas das legendas? Área de transferência Importar de Os ficheiros descarregados não serão importados. Terá de os voltar a descarregar manualmente Interface & Interação Tipo de Backup Exportar para Ficheiro Exportar o histórico de descargas? Importar o histórico de descargas? A exportar %1$s do histórico de descargas. Os ficheiros descarregados e as preferências não serão guardados. Histórico de descargas Exportar Importar Backup completo Importou %1$s para o histórico de descargas Descarregar novamente O vídeo foi descarregado. Se este não for o comportamento esperado, verifique o seu arquivo de descarga. Recipiente de vídeo Remux Remuxar vídeos para um recipiente MKV para uma melhor compatibilidade %1$d cookies de %2$d sites no total Mensalmente Diariamente Semanalmente Lista de reprodução Predefinição Escolha entre formatos, legendas e personalize ainda mais Editar predefinição Descarregar o melhor formato disponível Preferir %1$s Todos os idiomas Continuar Descarregar automaticamente utilizando as suas preferências de formato %d vídeo %d vídeos %d vídeos %d áudio %d áudios %d áudios Tarefa adicionada à fila Encontrará as suas descargas aqui Toque no botão de descarga ou partilhe uma ligação de vídeo para esta aplicação para iniciar uma descarga Descarregado Tudo Selecionar a partir de %1$d ligações Fila de descargas Mostrar gaveta de navegação Retomar Eliminar Informações da mídia Solução de problemas Rastreador de problemas Correção de erros comuns e verificação de problemas conhecidos Encontrou um erro? Antes de comunicar um novo problema, procure no nosso registo de problemas. Muitos problemas comuns já foram resolvidos e documentados lá. ================================================ FILE: app/src/main/res/values-ro/strings.xml ================================================ Salvează ca audio Salvează miniatura Setări General, format, comandă personalizată Descarcă Folosind cea mai recentă versiune de yt-dlp Preluand informatiile video-ului … Permisiunea refuzată Descarcă \"%1$s\" Versiunea YT-dlp Faceți clic pentru a instala cea mai recentă versiune yt-dlp Confirma Anulează Elimină\? Deschide link-ul Elimina Șterge fișierul Înainte Cea mai recentă versiune Video Credite Editați Temă întunecată Sistem Oprire Anulează Configurați înainte de a descărca Raport de eroare copiat în clipboard Miniatură Lipește Linkul nu poate fi gol Descarcă și salvează audio, în loc de video Salvează miniatura video ca fișier Nu s-a putut instala cea mai recentă versiune yt-dlp. Vă rugăm să vă asigurați că sunteți conectat la internet. Descărcarea s-a terminat Lipiți URL-ul din clipboard Nu s-a putut descărca fișierul Nu s-a putut prelua informații video General Nu s-a putut potrivi URL-ul din clipboard Descărcări Audio Link copiat în clipboard Despre Versiune, feedback, actualizare automată Căutați changelog-uri și versiuni noi Versiune Afişa Verificat Credite și software libre Avansat Temă întunecată, culoare dinamică, limbi Referințe de utilizare YT-dlp Ieșire detaliată Rulați comanda yt-dlp cu template personalizat Dosar video Selectează tot Selectarea șablonului Etichetă Elimină\? Descărcare în curs… Descărcați sarcina anulată Anulat Deschide fișierul Reîncepe Raport de eroare Specificați categoriile SponsorBlock pentru a elimina sau marca în fișierul video Căutați actualizări Cookie-uri Verificați automat cea mai recentă versiune pe GitHub Culoare dinamică Aplicați culori din imagini de fundal la tema aplicației Descărcați folosind telefonul mobil Permite descărcarea media atunci când este conectat la rețele măsurate Intrare invalidă Temă întunecată cu contrast ridicat Cea mai scăzută calitate Limită de rată, descărcare, cookie-uri Dezactivați previzualizarea Fără afișarea miniaturilor în timpul descărcării Confidențialitate Utilizați comanda personalizată Director privat Stocați descărcările într-un director ascuns Crop imagine încorporată în pătrat Selectarea formatului În așteptare Rata maximă Traduceți Rezoluție video Versiunea YT-dlp, notificare, playlist Fișierele temporare pot fi utilizate pentru a relua descărcările anulate. Ești sigur că vrei să ștergi toate aceste fișiere? \n \nPoți accesa aceste fișiere în %1$s Încorporează subtitrări furnizate în videoclipuri, dacă sunt disponibile Format de fișier, calitate video, subtitrări %1$d selectat Ajută la traducerea acestei aplicații pe Hosted Weblate Import din clipboard Rețea Limitați rata maximă pentru viteza de descărcare Copiază link-ul Utilizați aria2c ca descărcător extern Indisponibil Versiunea curentă este actualizată Actualizare Dimensiunea fișierului video %1$d video(uri), %2$d fișier(e) audio Elimină cookie-urile pentru \"%1$s\"? Șablon nou Preluare informații Dezactivează istoricul descărcărilor Categoriile SponsorBlock Video (fără audio) Eroare necunoscută %1$d Descărcați sarcini Adăugat recent Generați cookie-uri noi Acest fișier nu mai este disponibil Încorporează subtitrări Editați și gestionați șabloanele de comandă Informații copiate în clipboard Prefix Utilizați cookie-uri Nu a reușit să actualizeze la cea mai recentă versiune Eroare Efectuat Exportați în clipboard Ștergeți toate fișierele temporare din directorul temporar Incognito Descărcarea cu rețeaua celulară este dezactivată conform setărilor dvs Selectați formatul de descărcat înainte de a începe descărcarea Trimiteți o problemă pentru raportul de eroare sau cererea de funcții Eliminați definitiv \"%1$s\" din șabloanele de comandă\? Eliminați sau marcați segmente în videoclipuri cu SponsorBlock API Eliminați definitiv articolele %1$d din istoricul de descărcare\? Utilizați cookie-uri formatate Netscape pentru descărcări Ștergeți fișierele temporare Fișier(e) temporar(e) șters(e) %1$d Limită de rată Selectați videoclipuri de descărcat din playlist-ul \"%1$s” Sugestii Se preiau informații despre playlist… Selecția playlistului Sfârşit Interval de indexare invalid Descărcarea listei de redare (%1$d/%2$d)… Dosar audio Nespecificat (implicit) Format video preferat Format video Descarcă Ghid de utilizare Deschide setările Descărcare multi-threaded Director de descărcare Mai multe acțiuni Descărcare terminat. Atingeți pentru a deschide. Vă rugăm să setați utilizarea bateriei acestei aplicații la \"Nerestricționat” în setările sistemului pentru a o descărca în fundal. Nu se poate potrivi adresa URL din conținutul partajat Să nu mai apari din nou Apoi faceți clic pe \"Descarcă” după ajustarea setărilor. Notifică fișierele descărcate și progresul Închide Setari aditionale Notifică fișierele descărcate și progresul Link video Se rulează comenzi personalizate… Descărcați mai multe părți ale videoclipurilor M3U8/MPD în paralel Notificare de descărcare Faceți clic pe \"Lipește\" pentru a obține link-ul video din clipboard. Descarcă Opțiuni Formatul preferat atunci când sunt furnizate mai multe Verificați și gestionați descărcările în aplicație, inclusiv videoclipuri și fișiere audio. Implicit %.2f G Aplică Nelimitat Seal va fi întotdeauna gratuit și open source pentru toată lumea. Dacă îți place, te rog să mă sponsorizezi pe GitHub! Recodificarea fișierelor audio va duce la pierderea calității audio și la creșterea dimensiunii fișierului. Clipează video Convertiți formatul audio Format audio Nu există medii descărcate Beta Activează funcția experimentală\? Descărcările care utilizează această caracteristică vor fi delegate FFmpeg pentru a descărca secțiunile selectate ale videoclipului, această funcție este încă experimentală și tăierea nu va fi complet precisă, nu toate formatele acceptă această funcție și este posibil să aveți viteze de descărcare mai lente. Realizați videoclipuri în pagina de selecție a formatului %.2f M Convertiți în %1$s Limitați calitatea video atunci când sunt prezenți mai multe Pornire Descărcarea Subtitrarile pot fi greșite atunci când eliminați segmente SponsorBlock. Trimite Arată jurnal Jurnal Majoritatea platformelor de streaming video oferă audio și video separat, puteți selecta și îmbina un format numai audio cu un format numai video într-un singur videoclip. Actualizarea automată nu este disponibilă pentru versiunile %1$s. Dacă nu aveți %1$s instalat pe dispozitiv sau doriți să previzualizați funcțiile noi viitoare în Seal, vă rugăm să luați în considerare %2$s. trecerea la versiunile GitHub Ok Am înţeles Funcție indisponibilă Aruncă Pornire Pe Calitatea video Cea mai bună calitate Șablon(uri) %1$d importat(e) Configurați preferințele înainte de descărcare Aruncă o privire la setările de descărcare și asigură-te că ai cea mai recentă versiune de yt-dlp înainte de a o folosi. Șablon(uri) exportat(e) %1$d Ajustați această descărcare Se execută deja o sarcină de descărcare existentă Unele opțiuni nu sunt disponibile atunci când se utilizează comanda personalizată Șablon de comandă Descărcarea de pe unele site-uri necesită informații de autentificare a contului. Faceți clic pe „Generează cookie-uri noi”, introduceți adresa URL a site-ului web și apoi conectați-vă cu contul dvs. în pagina browserului, aplicația o va genera pentru dvs. Limba de afișare Descărcați mai multe videoclipuri dintr-un playlist Descărcați playlist-ul Problemă cu permisiunea de stocare Calea de ieșire și URL-ul vor fi adăugate de aplicație. Ignorați optimizarea bateriei pentru ca această aplicație să fie descărcată în fundal Spațiu Matrix Problemă GitHub Neconvertit Converti Salvare în subdirector Salvați fișierele în folderele numite ca și câmpurile respective Modul Multiselect Imprimă mesaje detaliate la descărcare Seal se descarcă… Setați limba de afișare Eliminați definitiv \"%1$s” din istoricul descărcărilor\? Verificați depozitul GitHub și README Comandă personalizată Începeți să executați comanda Se citește linkul video din conținutul partajat… Selectați unde să stocați fișierele video și audio Configurarea bateriei Cum funcționează\? Canalul Telegram Fără sarcini de comandă personalizate Pentru încorporarea subtitrărilor, videoclipurile vor fi remuxate în containerul mkv. Dosarul cardului SD Subtitrări automate Descărcați subtitrări generate automat Descărcare rapidă Subtitlu Descărcați subtitrări Limbi de subtitrare Limbi, subtitrări încorporate, subtitrări automate Copiază jurnalul Adaugă Comenzi rapide Editați comenzile rapide personalizate care pot fi utilizate pentru a compune șabloane de comandă. Stabil Preview-ul Instalați versiuni de pre-lansare pentru a previzualiza noile funcții și modificări. \n \nVa exista o anumită instabilitate în aceste versiuni, așa că vă rugăm să nu ezitați să ne oferiți feedback dacă întâmpinați probleme pentru a ne ajuta să îmbunătățim aplicația pentru viitor. Actualizați canalul Actualizare automată Activează actualizarea automată Sfârşit Formatul audio preferat Cel mai mic bitrate Calitate audio Limitați rata de biți audio atunci când sunt prezente mai multe calități Sortare format Sortarea formatelor cu opțiunea -S din yt-dlp Importă Titlu Redenumire secundă minut Ștergeți toate cookie-urile Ștergeți definitiv toate cookie-urile stocate în aplicație\? Sponsor Sprijină această aplicație prin sponsorizare pe GitHub Părere Sponsori Stocați fișierele temporare în directorul intern Format Specifica intervalul de videoclipuri pentru a descarca din playlist-ul \"%3$s\" (de la %1$d pana la %2$d). Mesaj de la dezvoltator Mulțumesc foarte mult! %d din thread-uri v-or fi folosit pentru a descărca video nativ DASH/HLS concurent. Decupeaza arta Locatiile din afara Download/ si Documents/ nu sunt suportate Sterge Exemplu text creator video Exemplu text titlu video Editează scurtături Rularea activităților Descărcați videoclipuri de la URL Convertiți subtitrări Convertiți subtitrările într-un alt format Împărțiți videoclipul Videoclipul va fi împărțit în %1$d capitole Hopa! Ceva n-a mers bine Copiați și ieșiți Extinde Sarcină nouă de descărcare Editați \"%1$s\" Pornire Actualizează yt-dlp ================================================ FILE: app/src/main/res/values-ru/strings.xml ================================================ Основные настройки, формат, своя команда Настройки Папка с видео Сохранить как аудио Сохранить миниатюру Скачать и сохранить аудио вместо видео Используется новейшая версия yt-dlp Не удалось установить новейшую версию yt-dlp. Пожалуйста, удостоверьтесь, что Вы подключены к интернету. Извлечение информации о видео… Сохранить миниатюру к видео как файл Не получается получить информацию о видео Установить язык Существующая загрузка уже запущена Вставлен URL-адрес Удалить \"%1$s\" из Вашей истории загрузок навсегда? Подтвердить Открыть ссылку Удалить Удалить файл Назад Версия, обратная связь, автообновление Новейшая версия Проверен Своя команда Подробный отчёт Тёмная тема Отменить Параметры перед загрузкой Настройте параметры перед загрузкой Выходной путь и URL-адрес будут добавлены приложением. Качество видео Не указано (по умолчанию) Предпочитаемый формат видео Начать загрузку Руководство пользователя Открыть настройки Ограничить качество видео, если доступны несколько Предпочитаемый формат, если доступны несколько Ознакомьтесь с настройками загрузки и убедитесь, что у Вас установлена новейшая версия yt-dlp, прежде чем использовать их. Загружайте сразу несколько видео из плейлиста Уведомлять о загруженных файлах и прогрессе Пожалуйста, установите в настройках контроля фоновой активности приложения значение \"Без ограничений\" для загрузки в фоновом режиме. Многопоточная загрузка Невозможно сопоставить URL-адрес из общего содержимого Чтение ссылки на видео из общего контента… Уведомлять о загруженных файлах и процессе Получение информации о плейлисте… Укажите диапазон видео для загрузки из плейлиста \"%3$s\" (от %1$d до %2$d). Начать Закончить Недопустимый диапазон номеров Выберите место для хранения видео- и аудио-файлов Запрет доступа к хранилищу Настройки энергопотребления Seal скачивает… Неизвестная ошибка Перевод Помогите с переводом на Hosted Weblate Загрузить Ссылка не может быть пустой Доступ запрещён Загрузка завершена Загружаю \"%1$s\" Основные Не удалось загрузить файл Версия yt-dlp Нажмите для установки новейшей версии yt-dlp Удалить\? Аудио Загрузки О приложении Ссылка скопирована в буфер обмена Отменить Версия Тёмная тема, динамические цвета, языки Продвинутые настройки Благодарности и свободное ПО Ознакомьтесь со списком изменений и новых версий Загляните в репозиторий GitHub и README Видео Благодарности Выводить подробный отчёт во время загрузки Запустить yt-dlp со своими настройками Внешний вид Править Сообщение об ошибке скопировано в буфер обмена Рекомендации по использованию yt-dlp Шаблон настроек Выполнить команду Миниатюра Вставить Как в системе Преобразовать в %1$s Преобразование аудиоформата Не преобразовывать Включена Отключена Перекодирование аудиофайлов создаст потерю качества и увеличит размер файла. Формат видео Наилучшее качество Преобразовать Закрыть Не показывать снова Затем нажмите \"Загрузить\" после настройки параметров загрузки. Формат Ссылка на видео Загрузка завершена. Нажмите для открытия. Выполнение пользовательской команды… Параллельно загружайте другие части видео M3U8/MPD Выбор в плейлисте %d Поток(ов) будут использоваться для одновременной загрузки видео DASH/HLS. Дополнительные параметры Параметры Показать больше действий Уведомление о загрузке Загрузка плейлиста (%1$d/%2$d)… Каталог загрузки Папка с аудио Сохранить в подкаталог Сохраняйте файлы в папках, названных местами, откуда был скачан материал Каталоги за пределами Download/ и Documents/ не поддерживаются Игнорируйте оптимизацию батареи, чтобы приложение загружало также и в фоне Нажмите \"Вставить\", чтобы вставить ссылку на видео из буфера обмена. Проверяйте и управляйте загрузками в приложении, включая видео- и аудио-файлы. Загрузка плейлиста По умолчанию Загрузить Язык приложения Не удалось найти подходящий URL-адрес в буфере обмена Настройте эту загрузку Шаблон пути Встраивание субтитров Встраивать в видеофайл пререндерные субтитры, если они доступны Новый шаблон Заголовок Удалить\? Удалить \"%1$s\" из шаблонов команд навсегда? Выбор шаблона Редактирование шаблонов команд и управление ими Загрузка в процессе… Эта загрузка отменена Отправьте сообщение о проблеме для сообщения об ошибке или запроса функции Информация, скопированная в буфер обмена Сообщить о проблеме (GitHub) Открыть файл Перезапустить Получение информации Завершено Загрузка В очереди Отменено Копировать отчет Ошибка Скопировать ссылку Разрешение видео Размер файла Импорт из буфера обмена Экспортировано %1$d шаблон(а/ов) Импортировано %1$d шаблон(а/ов) %1$d Загрузочная задача Недавно добавленные %1$d видеофайл(а/ов), %2$d аудиофайл(а/ов) Удалить %1$d элемент(а/ов) из Вашей истории загрузок навсегда\? Экспортировать в буфер обмена Категории SponsorBlock Удаление или пометка сегментов в видео с помощью SponsorBlock API Укажите категории SponsorBlock для удаления или отметки в видеофайле Проверить обновления Автоматически проверять наличие новейшей версии с GitHub\'а Уже установлена новейшая версия Не удалось обновиться до новейшей версии Обновление Используйте aria2c как внешний загрузчик Используйте файлы cookie в формате Netscape для загрузки Очистить временные файлы Удалить все временные файлы из временного каталога Удален(о) %1$d временный(х) файл(а/ов) Временные файлы можно использовать для возобновления отменённых загрузок. Вы уверены, что удалили все эти файлы? \n \nВы можете найти эти файлы в %1$s Режим мультивыделения Инкогнито Отключить историю загрузок Динамические цвета Примените цвета из обоев к теме приложения Этот файл больше недоступен Загружать при подключенной мобильной сети Разрешать загрузку медиа, когда подключена мобильная сеть с лимитным трафиком Согласно Вашим настройкам, загрузка через мобильную сеть отключена Сеть Ограничить максимальную скорость загрузки Высококонтрастная тёмная тема Недопустимый ввод Наименьшее качество Недоступно Формат файла, качество видео, субтитры Версия Yt-dlp, уведомление, плейлист Ограничить скорость, загрузчик, файлы cookie Отключить миниатюру Не отображать миниатюры во время загрузки Использовать свою команду Конфиденциальность Обрезать изображение Обрезать вставленное изображение в квадрат Приватный каталог Сохранять загрузки в скрытый каталог Максимальная скорость Скоростной лимит Выбрано %1$d видео Выберите видео для загрузки из плейлиста \"%1$s\" Выбрать все Выбор формата для загрузки перед её началом Сгенерировать новые файлы cookie Видео (без аудио) Выбор формата Предложенный Использовать файлы cookie Как это работает\? Удалить эту запись для \"%1$s\"? Обратите внимание, что файлы cookie, сохранённые на этом сайте, не будут удалены. Telegram канал Некоторые настройки недоступны, когда используется своя команда Загрузка с некоторых сайтов требует информацию для авторизации аккаунта. Нажмите \"Сгенерировать новые файлы cookie\", введите URL-адрес сайта и затем войдите в ваш аккаунт на странице браузера, приложение создаст это для Вас. Матричное пространство Cookies Автоматические субтитры Скопировать лог Очистить Добавить Запущенные задачи Показать лог Лог Папка SD карты Загружать автоматически сгенерированные субтитры Ярлыки Редактировать ярлыки Языки субтитров Загружать субтитры Быстрая загрузка Субтитры Языки, встроенные субтитры, автоматические субтитры Тайминги субтитров могут быть нарушены при удалении сегментов SponsorBlock. Изменяйте ярлыки, которые могут быть использованы для составления своих команд. Пример названия видео Пример автора видео Чтобы встроить пререндерные субтитры, контейнер видео будет изменен на mkv. Вы можете использовать Проигрыватель VLC или другие совместимые приложения для просмотра видео с пререндерными субтитрами. %.2f МБ %.2f ГБ Поделиться Канал обновлений Предварительный Стабильный Устанавливайте предварительные версии, чтобы ознакомиться с новыми функциями и изменениями. \n \nЭти версии могут быть немного нестабильными поэтому, пожалуйста, не стесняйтесь сообщать нам, если у вас возникнут какие-то проблемы, это поможет нам улучшить приложение в будущем. Автообновление Включить автообновление Большинство стриминговых сервисов поставляют аудио и видео раздельно. Вы можете выбрать и объединить форматы только аудио с только видео в одно видео. Конец Обрезать видео Отменить Применить Начало Качество аудио Без ограничений Предпочитаемый формат аудио Переименовать Импортировать Название Сортировка форматов Сортировка форматов с помощью параметра -S в yt-dlp Очистить все файлы cookies Самый низкий битрейт Удалить все файлы cookies, хранящиеся в приложении, навсегда\? Ограничить битрейт, когда несколько качеств доступны секунда минута Хранить временные файлы во внутреннем каталоге Спонсировать Поддержать приложение путём спонсирования на GitHub Обратная связь Спонсоры Seal всегда будет бесплатным и с открытым исходным кодом для всех. Если вам нравится это приложение, пожалуйста, подумайте о спонсорстве на GitHub! Формат аудио Бета Включить экспериментальную функцию\? Функция недоступна Нет скачанных медиафайлов Создание видеороликов на странице выбора формата переход на сборки с GitHub Ок Понятно Загрузки с использованием этой функции будут делегированы FFmpeg для загрузки выбранных фрагментов видео, эта функция все ещё является экспериментальной, и нарезка будет не совсем точной, не все форматы поддерживают эту функцию, и вы можете столкнуться с более низкой скоростью загрузки. Автообновление недоступно для сборок %1$s. Если на вашем устройстве не установлено приложение %1$s или вы хотите предварительно ознакомиться с новыми функциями Seal, рассмотрите вариант %2$s. Нет пользовательских командных задач Большое спасибо! Сообщение от разработчика Скачать видео с URL-адреса Конвертировать субтитры Конвертировать субтитры в другой формат Разделить видео Видео будет разделено на %1$d глав Ой! Что-то пошло не так Скопировать и выйти Развернуть Новая задача на загрузку Начать Редактировать \"%1$s\" Обновить yt-dlp Прокси Нажмите чтобы задать путь Кастомный путь команды Выбор папки Используйте прокси для соединения с интернетом Классический Высокое разрешение Включить уведомления\? Приложение требует ваше разрешение на отправку уведомлений для отображения стадии процесса загрузки. Отключить Отключено Используется MP4(H.264) формат для взаимодействия с другими приложениями Используется AV1, VP9 или H.265 формат для просмотра в совместимых приложениях Укажите путь вывода при использовании кастомных команд Автоматический Пользовательский Тип загрузки Предпочитаемый формат Узнать больше Команды Неизвестный Нажмите, чтобы открыть веб-страницу для создания новых файлов cookie: Удалить %1$s из сохранённых команд\? %d предмет %d предметы %d предметы %d предметы Заголовок User-Agent Экспорт в файл yt-dlp — мощный инструмент командной строки для загрузки видео. Seal упрощает использование yt-dlp, предоставляя интуитивно понятный графический интерфейс, предустановки для общих команд и другие дополнительные функции. \n \nДля расширенного использования yt-dlp Seal позволяет создавать, сохранять и выполнять собственные шаблоны команд напрямую, как в терминале. \n \nПри использовании пользовательских команд большинство параметров и функций графического интерфейса будут отключены. Пресеты Вывод шаблона Укажите шаблон для имён выходных файлов Очистить архив загрузок\? Удалить %1$s из файла архива навсегда\? Записывать ID загруженных видео в архив во избежание дубликатов Архив загрузок Встроить метаданные Необходимый Встроить метаданные и миниатюру видео в аудиофайл Показать все элементы (%1$d) Сохранить Использовать сортировку по формату Редактировать файл Ограничьте имена файлов определенными символами, чтобы обеспечить совместимость Ограничить имена файлов Веб-сайт Название плейлиста Ваши загрузки будут сохранены как: Настройки системы Принудительно использовать IPv4 Выполнять все подключения через IPv4 Оставить файлы субтитров Только сейчас Разрешать загрузку через мобильную сеть? Всегда разрешать Не разрешать Объединение нескольких аудио Разрешить объединение нескольких аудиопотоков в один файл Поиск Поиск в загрузках Субтитры с автопереводом В загрузках можно будет выбрать субтитры, созданные автоматически для каждого языка. Они могут быть неточными и трудными для понимания. Запомнить для будущей загрузки Оформление Использовать прошлый выбор Язык субтитров для загрузки в автоматическом выборе формата, через запятую. Перезагрузить Поиск в субтитрах Нет, спасибо Следующие языки будут добавлены в список ваших предпочтений для будущих загрузок: Обновить языки субтитров? Тип резервной копии Файл Буфер обмена Экспортировать историю загрузок? Экспорт Экспортировать в Импортировать из Импортировать историю загрузок? Импорт Полное резервное копирование Загруженные файлы не будут импортированы. Вам придётся загрузить их обратно вручную Экспорт %1$s из истории загрузок. Загруженные файлы и настройки не будут скопированы. Интерфейс и взаимодействие История загрузок Импортирован %1$s в историю загрузок Скачать заново Видео было загружено. Если это не ожидаемое поведение, пожалуйста, проверьте архив загрузки. Нет Remux видео контейнер Кол-во файлов куки: %1$d, кол-во их источников: %2$d Конвертировать видеофайлы в MKV контейнеры для улучшения совместимости Каждый день Каждую неделю Каждый месяц Плейлист Пресет Предпочитать %1$s Выберите из форматов, субтитров и настройте дальше Все языки Продолжить Задача добавлена в очередь %d видео %d видео %d видео %d видео %d аудио %d аудио %d аудио %d аудио Скачать автоматически с помощью предпочтений формата Изменить предустановку Скачать лучший доступный формат Вы найдете свои загрузки здесь Нажмите на кнопку загрузки или поделитесь ссылкой на видео с этим приложением, чтобы начать загрузку Загружено Все Выберите из %1$d ссылок Показать навигационную панель Резюме Удалить Информация о медиа Очередь загрузки Устранение неисправностей Отслеживание проблем Устранение распространённых ошибок и проверка на наличие известных ошибок Возникла ошибка? Прежде чем сообщать о новой проблеме, воспользуйтесь нашим отслеживанием проблем. Многие распространённые проблемы уже решены и задокументированы там. ================================================ FILE: app/src/main/res/values-si/strings.xml ================================================ සාමාන්‍ය, ආකෘතිය, අභිරුචි විධාන බාගත කරන්න වීඩියෝ තොරතුරු ලබා ගනිමින්… බාගත කිරීම අවසන් බාගැනීම \"%1$s\" වීඩියෝ තොරතුරු ලබා ගැනීමට නොහැකි විය සාමාන්‍ය සැකසුම් සංදර්ශක භාෂාව සකසන්න ක්ලිප්බෝඩයේ ඇති URL ය සමඟ ගැළපීමට නොහැකි විය Yt-dlp අනුවාදය ඉවත් කරන්න\? ඔබේ බාගැනීම් ඉතිහාසයෙන් \"%1$s\" ඉවත් කරන්නද\? තහවුරුයි අවලංගු කරන්න බාගැනීම් ඕඩියෝ සබැඳිය ක්ලිප්බෝඩයට පිටපත් කරන ලදී සබැඳිය විවෘත කරන්න ඉවත් කරන්න මකන්න විස්තර අනුවාදය, නිකුත් කිරීම්, ස්තූතීන් ආපසු GitHub ගබඩාව සහ README පරීක්ෂා කරන්න විධානය ක්‍රියාත්මක කරන්න විස්තරාත්මක ප්‍රතිදාන බාගත කිරීමේදී විස්තරාත්මක පණිවිඩ පෙන්වන්න සංදර්ශක සැකසුම් සක්‍රීයයි බාගත කිරීමට පෙර වින්‍යාස කරන්න Yt-dlp භාවිතයන් උසස් ගුණාත්මකතාව (default) ආකෘතියේ ගුණාත්මකභාවය සහ විශාලත්වය සීමා කිරීම බහු බාගැනීම් වලදී තෝරාගත් ආකෘතිය වසන්න නැවත නොපෙන්වන්න පරිශීලක මාර්ගෝපදේශ සැකසුම් පෙන්වන්න Playlist බාගන්න බාගැනීම් සහ ප්‍රගතිය දන්වන්න වීඩියො සබැඳිය බෙදාගත් අන්තර්ගතයෙන් URL ගැලපීමට නොහැක බෙදාගත් අන්තර්ගතයෙන් වීඩියෝ සබැඳිය කියවමින් පවතී… Seal බාගත කරගනිමින් පවතී… නොදන්නා දෝෂයක් පරිවර්තනය යෙදුම Hosted Weblate මත පරිවර්තනය කිරීමට සහාය වන්න ඉවත් කරන්න\? බාගැනීම අවලංගු කරන ලදී තොරතුරු ක්ලිප්බෝඩයට පිටපත් විය බාගනිමින් අවලංගුයි තොරතුරු ගනිමින් පවතී දෝෂ වාර්තාව වීඩියෝ විභේදනය මෑතකදී එකතු කිරීම් ඔබගේ බාගැනීම් ඉතිහාසයෙන් %1$d ඉවත් කරන්නද\? SponsorBlock API මගින් වීඩියෝ වල කොටස් ඉවත් කරන්න SponsorBlock ප්‍රවර්ගයන් නව අනුවාදයක් නොමැත යාවත්කාලීන කරන්න බාහිර බාගැනීම් සදහා aria2c භාවිතා කරන්න බාගැනීම් සඳහා Netscape ආකෘතිගත Cookies භාවිතා කරන්න අවලංගු කළ බාගැනීම් නැවත ආරම්භ කිරීමට තාවකාලික ගොනු භාවිතා කළ හැක. මෙම ගොනු සියල්ල මකන්නද\? බහුවරණ තේරීම් බාගැනීම් ඉතිහාසය අක්‍රිය කරන්න ගතික වර්ණ යෙදුම් තේමාවට බිතුපත්වල වර්ණ යොදන්න සෙලියුලර් ඩේටා භාවිතයෙන් බාගන්න සීමාගත ජාල තුළ බාගැනීමට අවසර ලබාදෙන්න මෙම ගොනුව තවදුරටත් නොමැත වේග සීමාව උපරිම සීමාව තද අදුරු තේමාව වලංගු නොවන ආදානයකි නොපවතී ගොනු ආකෘතිය, වීඩියෝ ගුණාත්මකභාවය, උපසිරැසි පෙරදසුන අක්‍රිය කරන්න බාගැනීම්වල පෙරදසුන අබල කරන්න පෞද්ගලිකත්වය අභිරුචි විධාන භාවිතා කරන්න පුද්ගලික ඩිරෙක්ටරිය සැඟවූ ඩිරෙක්ටරියක බාගත කිරීම් ගබඩා කරන්න වීඩියෝ වෙනුවට ශ්‍රව්‍ය බාගත කර සුරකින්න වීඩියෝ ගොනුව ශබ්දය ලෙස සුරකින්න සැකසුම් සබැඳිය හිස් නොවිය යුතුයි yt-dlp නවතම අනුවාදය භාවිත කරයි අවසර නොලැබුණි නවතම yt-dlp අනුවාදය ස්ථාපනය කිරීමට නොහැකි විය. කරුණාකර ඔබ අන්තර්ජාලයට සම්බන්ධ වී ඇති බව සහතික කර ගන්න. ගොනුව බාගැනීමට නොහැකි විය ක්ලිප්බෝඩයෙන් URL පේස්ට් කරන්න නවතම yt-dlp අනුවාදය ස්ථාපනය කිරීමට ක්ලික් කරන්න සංදර්ශක භාෂාව පවතින බාගැනීම් කාර්යයක් දැනටමත් ක්‍රියාත්මක වේ අනුවාදය වෙනස් කිරීම් සහ නව අනුවාද නවතම අනුවාදය වීඩියෝ අභිරුචි විධානයන් අභිරුචි ටෙම්ප්ලේට සමඟ yt-dlp විධානය ක්‍රියාත්මක කරන්න විධාන ටෙම්ප්ලේට සංස්කරණය අඳුරු තේමාව, ගතික වර්ණය, භාෂා අඳුරු තේමාව පද්ධතිය අක්‍රීයයි අවලංගු කරන්න බාගැනීමට පෙර සකසන්න ඕඩියෝ ආකෘතිය පරිවර්තනය කරන්න මෙම බාගැනීම සකසන්න දෝෂ වාර්තාව ක්ලිප්බෝඩයට පිටපත් විය පේස්ට් ප්‍රතිදාන ස්ථානය සහ URL යෙදුම මගින් එක් කරනු ඇත. %1$s ලෙස පරිවර්තනය කරන්න ආකෘතිය MP3 හෝ M4A ආකෘති බොහෝ උපාංගවල ක්‍රියා කරයි වීඩියෝ ගුණාත්මකතාවය නිශ්චිතව දක්වා නැත (default) වීඩියෝ ආකෘතිය වීඩියෝ ආකෘතිය පරිවර්තනය කරන්න බාගත කරන්න ක්ලිප්බෝඩයෙන් වීඩියෝ සබැඳිය ලබා ගැනීමට \"පේස්ට්\" ක්ලික් කරන්න. සැකසුම් සකස් කිරීමෙන් පසු \"බාගත කරන්න\" ක්ලික් කරන්න. අභිරුචි විධානයන් ක්‍රියාත්මක වෙමින්… බාගැනීම් සැකසුම් බලන්න සහ නවතම yt-dlp අනුවාදයෙහි සිටීදැයි තහවුරු කරගන්න. වීඩියෝ රැසක් එකවර playlist මගින් බාගන්න බාගන්න කොටස් ගණනාවකින් බාගන්න බාගැනීම අවසන්. විවෘත කිරීමට ක්ලික් කරන්න. කරුණාකර පසුබිම් බාගැනීම් සිදුකිරීමට යෙදුමේ බැටරි භාවිතය පද්ධති සැකසීම් තුළ \"Unrestricted\" ලෙස සකසන්න. M3U8/MPD වීඩියෝවල කොටස් සමාන්තරව බාගත කරන්න Playlist තොරතුරු ලබා ගනිමින්… විකල්ප අමතර සැකසුම් නවතම අනුවාදයට යාවත්කාලීන කිරීමට අසමත් විය තාවකාලික ගොනු ඉවත් කරන්න බාගැනීම් නාමාවලියෙන් සියලුම තාවකාලික ගොනු මකන්න තවත් විකල්ප පෙන්වන්න බාගත කිරීම් දැන්වීම් තාවකාලික ගොනු %1$dක් මකා දමන ලදී බාගත කළ ගොනු සහ ප්‍රගතිය දැනුම් දෙන්න Playlist තේරීම \"%3$s\" ධාවන ලැයිස්තුවෙන් බාගත කිරීමට වීඩියෝ පරාසය සඳහන් කරන්න (%1$d සිට %2$d දක්වා). පටන්ගන්න ඕඩියෝ ගොනුව වීඩියෝ සහ ශ්‍රව්‍ය ගොනු ගබඩා කළ යුතු ස්ථානය තෝරන්න අවසන් කරන්න වලංගු නොවන දර්ශක පරාසයකි පවතී නම් වීඩියෝවලට උපසිරැසි ඇතුළත් කෙරේ ධාවන ලැයිස්තුව බාගනිමින් පවතී (%1$d/%2$d), නතර කිරීමට නවත්වන්න ක්ලික් කරන්න. බාගැනීම් ඩිරෙක්ටරිය ඔබගේ සැකසීම් අනුව සෙලියුලර් ජාලයන් මගින් බාගැනීම අක්‍රිය කර ඇත අදාළ වෙබ් අඩවිය ලෙස නම් කර ඇති ෆෝල්ඩරවල ගොනු සුරකින්න Download/ සහ Documents/ ඩිරෙක්ටරිවලින් පිටත සහාය නොදක්වයි බැටරි කළමනාකරණය උප ඩිරෙක්ටරිය තුළ සුරකින්න පසුබිම් බාගැනීම් සදහා බැටරි ප්‍රශස්තකරණය නොසලකා හරින්න ජාලය බාගැනීම් සඳහා උපරිම වේගය සීමා කරන්න ආචයන අවසර ගැටලුවකි අනුපාත සීමාව, බාගත කරන්නා, cookies Yt-dlp අනුවාදය, දැනුම්දීම්, playlist අන්තර්ගත රූපය චතුරස්‍රාකාර ලෙස කපන්න උපසිරැසි ඇතුළත් කරන්න ලේබල් බාගනිමින් පවතී, අවලංගු කිරීමට ක්ලික් කරන්න. GitHub වාර්තා කිරීම් යළි අරඹන්න ගැටලු හෝ විශේෂාංග දැනුම්දීමට Issue ලෙස වාර්තා කරන්න සම්පූර්ණයි ක්ලිප්බෝඩයෙන් ලබාගන්න යාවත්කාලීනයන් පරීක්ෂා කරන්න අඩුම ගුණාත්මකතාව ගොනුව විවෘත කරන්න ගැටලුවක් සබැඳිය පිටපත් කරන්න වීඩියෝ ගොනු විශාලත්වය ක්ලිප්බෝඩයට යවන්න බාගැනීම් කාර්ය %1$d %1$d වීඩියෝ, %2$d ශ්‍රව්‍ය ගොනු වීඩියෝ ගොනුවෙන් ඉවත් කළ යුතු SponsorBlock ප්‍රවර්ග සඳහන් කරන්න GitHub හි නවතම අනුවාද ස්වයංක්‍රීයව පරීක්ෂා කරන්න සංක්ෂිප්ත රූපය සුරකින්න වීඩියෝ සංක්ෂිප්ත රූපය ගොනුක් ලෙස සුරකින්න පරීක්ෂා කළා ස්තූතීන් සහ libre මෘදුකාංග උසස් සැකසුම් සංක්ෂිප්ත රූපය පරිවර්තනය නොවේ යෙදුමේ බාගැනීම් හසුරුවන්න, වීඩියෝ සහ ශ්‍රව්‍ය ගොනු ඇතුලුව. පෙරනිමිය DASH/HLS වීඩියෝ සමගාමීව බාගැනීමට ත්‍රෙඩ් %d ක් භාවිතා කරනු ඇත. ආචයන ටෙම්ප්ලේට ස්ථානය නව ටෙම්ප්ලේටයක් විධාන ටෙම්ප්ලේට වලින් \"%1$s\" ඉවත් කරන්නද\? ටෙම්ප්ලේට තෝරා ගැනීම් විධාන ටෙම්ප්ලේට සංස්කරණය සහ කළමනාකරණය පේළිගතයි පුද්ගලික පැතිකඩ රූපය ක්‍රෝප් කරන්න ස්තූතීන් ටෙම්ප්ලේට %1$d ක් ආයාත කරන ලදී ටෙම්ප්ලේට %1$d ක් නිර්යාත කරන ලදී සියල්ල තෝරන්න %1$d තෝරාගන්නා ලදී \"%1$s\" ධාවන ලැයිස්තුවෙන් වීඩියෝ බාගැනීම සදහා තෝරන්න SD card ගොනුව ඉක්මන් බාගත කිරීම් වීඩියෝව (ශ්‍රව්‍ය නැත) කුකීස් යෝජිත ================================================ FILE: app/src/main/res/values-sk/strings.xml ================================================ Nastavenia Uložiť miniaturu Všeobecné, formát, vlastný príkaz Sťahovanie Stiahnuť a uložiť zvuk namiesto videa Uložiť miniatúru videa ako súbor Používate najnovšiu verziu yt-dlp Získavanie informácií o videu… Sťahovanie „%1$s“ Obecné Jazyk Nastaviť jazyk Vložená adresa zo schránky Yt-dlp verzia Aktualizovať yt-dlp Navždy odstrániť „%1$s“ z histórie sťahovania? Potvrdiť Zrušiť Zvuk Odkaz skopírovaný do schránky Odstrániť súbor O aplikácií Verzia, spätná väzba, automatické aktualizácie Zložka videí Uložiť ako zvuk Odkaz nemôže byť prázdny Sťahovanie je dokončené Inštalácia najnovšej verzie yt-dlp zlyhala. Skontrolujte prosím pripojenie k internetu. Sťahovanie súboru zlyhalo Oprávnenie zamietnuté Získavanie informácií o videu zlyhalo Nepodarilo sa nájsť adresu v schránke Už prebieha iná sťahovacia úloha Kliknite pre nainštalovanie najnovšej verzie yt-dlp Odstrániť? Stiahnuté súbory Otvoriť odkaz Odstrániť Späť Verzia Otvoriť zoznamy zmien a nové verzie Najnovšie vydanie Najnovšie vydanie Video Skontrolované Poďakovanie Vlastné príkazy Upraviť ­ Pokročilé ================================================ FILE: app/src/main/res/values-sl/strings.xml ================================================ Video mapa Shrani sličico Nastavite Shrani kot avdio ================================================ FILE: app/src/main/res/values-sr/strings.xml ================================================ Затим, додирните на „Преузми“ након прилагођавања подешавања. Погледајте подешавања преузимања и уверите се да имате најновију верзију yt-dlp пре коришћења. Преузми плејлисту Преузмите више видео снимака с плејлисте Подразумевано Ажурирање на најновију верзију није успело Онемогућите историју преузимања Преузимање Обавештење о преузетим фајловима и напретку Преузимање је завршено. Додирните да бисте отворили. Покретање прилагођених команди… Подесите потрошњу батерије ове апликације на „Неограничено“ у системским подешавањима да би се преузимање наставило у позадини. Опције Додатна подешавања Избор плејлисте Фолдер за аудио снимке Игноришите оптимизацију батерије за ову апликацију да бисте преузимали у позадини Преузимање информација о плејлисти… Преузимање плејлисте (%1$d/%2$d)… Сачувајте фајлове у фолдере који се називају као одговарајућа поља Фолдери изван Download/ и Documents/ нису подржани Непозната грешка Помозите да преведете ову апликацију на Hosted Weblate Ознака Заувек уклонити „%1$s“ из командних шаблона\? Користите aria2c као спољни програм за преузимање Привремени фајлови се могу користити за наставак отказаних преузимања. Желите ли заиста да избришете све ове фајлове\? \n \nОвим фајловима можете приступити у %1$s Избор шаблона Преузимање је у току… Задатак преузимања је отказан Информације су копиране у привремену меморију Стављено у редослед Отказано GitHub пријава проблема Пријавите проблем или пошаљите захтев за нову функцију Избришите све привремене фајлове из привременог фолдера Динамичка боја Yt-dlp верзија, обавештење, плејлиста Ограничите максималну брзину преузимања Недоступно Фолдер за видео снимке Преузми Линк не може бити празан Преузимање информација о видео снимку… Дозвола је одбијена Преузимање је завршено Није могуће прикупити информације о видео снимку Језик Није могуће пронаћи одговарајућу URL адресу у привременој меморији Заувек уклонити „%1$s“ из историје преузимања\? Уклонити\? Откажи Преузимања Отвори линк Избриши фајл Верзија, повратне информације, аутоматско ажурирање Кредити и слободни софтвер Прилагођена команда Покрените команду yt-dlp с прилагођеним шаблоном Командни шаблон Измени Започето је извршавање команде Напредно Детаљан излаз информација Приказивање детаљних порука приликом преузимања Приказ Искључено Откажи Конфигурација пре преузимања Подесите преференце пре преузимања Прилагодите ово преузимање Извештај о грешци је копиран у привремену меморију Сличица Поновно кодирање фајлова аудио снимака узроковаће губитак квалитета звука и повећање величине фајла. Квалитет видео снимка Ограничите квалитет видео снимка, када их је доступно више Претвори Упутство за употребу Отвори подешавања Додирните на „Налепи“ да бисте добили линк видео снимка из привремене меморије. Проверите и управљајте преузимањима у апликацији, укључујући фајлове видео и аудио снимака. Линк видео снимка Вишенитно преузимање Преузмите више делова M3U8/MPD видео снимака паралелно %d нит(и) би се користило за истовремено преузимање DASH/HLS изворних видео снимака. Није могуће подударање URL адресе из дељеног садржаја Читање линка видео снимка из дељеног садржаја… Прикажи још радњи Обавештење о преузимању Обавештење о преузетим фајловима и напретку Одредите опсег видео снимака за преузимање с плејлисте „%3$s“ (од %1$d до %2$d). Почетак Крај Неважећи опсег индекса Фолдер за преузимања Изаберите где ће се чувати фајлови видео и аудио снимака Сачувај у потфолдер Проблем с дозволом за приступ меморији Конфигурација батерије Seal преузима… Преведи Шаблон путање Угради титлове Уградите обезбеђене титлове у видео снимке, ако су доступни Нови шаблон Уклонити\? Измењујте и управљајте командним шаблонима Завршено Преузимање Отвори фајл Поново покрени Величина фајла видео снимка Извезен(о) %1$d шаблон(а) Задаци преузимања: %1$d Недавно додато %1$d видео фајл(а/ова), %2$d аудио фајл(а/ова) Уклоните или означите сегменте из видео снимака помоћу SponsorBlock API SponsorBlock категорије Провера ажурирања Аутоматска провера најновије верзије на GitHub-у Тренутна верзија је ажурна Ажурирај Брисање привремених фајлова Избрисан(о) је %1$d привремени(х) фајл(а/ова) Режим вишеструког избора Инкогнито Примените боје с позадина на тему апликације Преузимање помоћу мобилних података Преузимање путем мобилних података је онемогућено у складу с вашим подешавањима Овај фајл више није доступан Ограничење брзине Максимална брзина Тамна тема високог контраста Неважећи унос Формат фајла, квалитет видео снимка, титлови Онемогући преглед Приватност Користи прилагођену команду Приватни фолдер Исеците уграђену слику у квадрат Исеци слику Изаберите видео снимке за преузимање с плејлисте „%1$s“ Изабери све Изабрано: %1$d Сачувај као аудио снимак Сачувај сличицу Подешавања Општа, команда формат, прилагођена команда Сачувајте сличицу видео снимка као фајл Коришћење најновије верзије yt-dlp Није могуће инсталирати најновију верзију yt-dlp. Проверите да ли сте повезани на интернет. Преузимање „%1$s“ Није могуће преузети фајл Опште Подеси језик Налепљена URL адреса Постојећи задатак преузимања је већ покренут Преузмите и сачувајте аудио снимак, уместо видео снимка Yt-dlp верзија Кликните да бисте инсталирали најновију yt-dlp верзију Проверите GitHub репозиторијум и README Видео снимци Није наведено (подразумевано) Потврди Аудио снимци Верзија Линк је копиран у привремену меморију Уклони О апликацији Назад Потражите евиденције промена и нове верзије Најновије издање Формат Проверено Кредити Прикупљање информација Грешка Увезен(о) %1$d шаблон(а) Копирај линк Копирај извештај Мрежа Најнижи квалитет Резолуција видео снимка Тамна тема, динамичка боја, језици Налепи Тамна тема Системски Укључено Претвори формат звучног снимка Референце о употреби yt-dlp Апликација ће додати излазну путању и URL адресу. Претвори у %1$s Преферирани формат видео снимка Непретворено Формат видео снимка Најбољи квалитет Извези у привремену меморију Увези из привремене меморије Преферирани формат, када их је доступно више Затвори Не приказуј поново Преузми Користите Netscape форматиране колачиће за преузимања Заувек уклонити %1$d предмет(а) из историје преузимања\? Наведите SponsorBlock категорије које ће бити уклоњене или означене у фајлу видео снимка Дозволите преузимање медија када сте повезани на мреже с ограничењем Ограничење брзине, програм за преузимање, колачићи Нема приказа сличица током преузимања Чувајте преузимања у скривеном фолдеру Предложено Изаберите формат за преузимање пре него што започнете преузимање Видео снимци (без звука) Избор формата Генериши нове колачиће Користи колачиће Неке опције су недоступне када користите прилагођену команду Како то функционише\? Telegram канал Желите ли да уклоните овај унос за „%1$s“? Имајте на уму да колачићи сачувани за ову страницу неће бити обрисани. Преузимање с неких сајтова захтева информације о аутентификацији налога. Кликните на „Генериши нове колачиће“, унесите URL адресу веб-сајта, а затим се пријавите са својим налогом на страници у прегледачу и апликација ће их генерисати за вас. Колачићи Matrix простор Већина платформи за стримовање видео снимака одвојено испоручује аудио и видео снимке, можете изабрати и спојити формат само за аудио снимак с форматом само за видео снимак, у један видео снимак. Фолдер SD (меморијске) картице Аутоматски титлови Преузмите аутоматски генерисане титлове Брзо преузимање Пример текста наслова видео снимка Пример текста креатора видео снимка Преузми титлове Језици титлова Титлови Језици, уграђени титлови, аутоматски титлови Копирај евиденцију Избриши Измени пречице Додај Пречице Измените прилагођене пречице које се могу користити за састављање командних шаблона. Покренути задаци Прикажи евиденцију Евиденција Приликом уклањања SponsorBlock сегмената, титлови могу бити несинхронизовани. Да би се уградили обезбеђени титлови, видео снимци ће бити пребачени у mkv контејнер. Можете користити VLC Media Player или друге компатибилне апликације за гледање видео снимака с обезбеђеним титловима. %.2f ГБ %.2f МБ Дели Стабилан Бета Аутоматско ажурирање Омогући аутоматско ажурирање Инсталирајте бета издања апликације да бисте прегледали нове функције и промене. \n \nБиће неких нестабилности у овим верзијама, тако да не оклевајте да нам дате повратне информације ако наиђете на било какве проблеме, како бисте нам помогли да побољшамо апликацију у будућности. Канал ажурирања Примени Одбаци Исеци видео снимак Крај Почетак Преферирани формат аудио снимка Квалитет аудио снимка Ограничите брзину преноса аудио снимка, када је доступно више квалитета Неограничено Сортирање формата секунда Сортирање формата с опцијом -S од yt-dlp минут Најнижа брзина преноса Наслов Увези Заувек избришите све колачиће сачуване у апликацији\? Преименуј Избриши све колачиће Чувајте привремене фајлове у унутрашњем фолдеру Повратне информације Спонзори Seal ће увек бити бесплатан и отвореног кода за све. Ако вам се свиђа, размислите о томе да ме спонзоришете на GitHub-у! Спонзориши Подржите ову апликацију спонзорисањем на GitHub-у Формат аудио снимка Бета Направите исечке видео снимка на страници за избор формата Нема преузетих медија Омогућити експерименталну функцију\? Преузимања која користе ову функцију биће послата на FFmpeg да преузму изабране делове видео снимка, ова функција је и даље експериментална и сечење неће бити потпуно тачно, не подржавају сви формати ову функцију и можда ћете имати спорије брзине преузимања. прелазак на GitHub издања Аутоматско ажурирање није доступно за %1$s издања. Ако немате инсталирано %1$s на свом уређају или бисте желели да прегледате предстојеће нове функције у Seal-у, размислите о %2$s. Разумем У реду Функција није доступна Нема прилагођених командних задатака Порука од програмера Хвала много! Преузмите видео снимке с URL адресе Подели видео снимак Видео снимак ће бити подељен на %1$d поглавља Копирај и напусти Конвертујте титлове у други формат Конвертуј титлове Упс! Нешто није у реду Прошири Нови задатак преузимања Измени „%1$s“ Започни Застарело Омогућити обавештења\? Онемогући Додирните да бисте подесили фолдер Фолдер прилагођене команде Бирач фолдера Одредите излазни фолдер, када користите прилагођене команде Преферирање AV1, VP9 или H.265 формата за гледање у компатибилним апликацијама Ажурирај yt-dlp Прокси Користите прокси за интернет везе Квалитет Апликацији је потребна ваша дозвола да би постављала обавештења о статусу и напретку преузимања. Онемогућено Преферирање MP4(H.264) формата за дељење с другим апликацијама Тип преузимања Преференција формата Аутоматски Прилагођен Команде Сазнај више Непознато Додирните да отворите веб-страницу за генерисање нових колачића: Заувек уклонити %1$s из командних шаблона\? %d предмет %d предмета %d предмета Заглавље User-Agent Извези у фајл yt-dlp је моћна алатка командне линије за преузимање видео снимака. Seal олакшава коришћење yt-dlp-а тако што пружа интуитивни GUI, унапред подешене вредности за уобичајене команде и друге додатне функције. \n \nЗа напредну употребу yt-dlp-а, Seal вам омогућава да направите, сачувате и извршите прилагођене командне шаблоне директно, баш као у терминалу. \n \nКада користите прилагођене команде, већина GUI опција и функција би била онемогућена. Унапред подешено Излазни шаблон Одредите шаблон за називе излазних фајлова Очистити архиву преузимања\? Заувек уклонити %1$s из архивског фајла\? Сачувајте ID-ове преузетих видео снимака у архиву да бисте избегли дуплирана преузимања Архива преузимања Угради метаподатке Уградите метаподатке и сличицу видео снимка у фајл аудио снимка Обавезно Прикажи све предмете: %1$d Сачувај Користи сортирање формата Измените фајл Ограничите називе фајлова на одређене знакове да бисте осигурали компатибилност Ограничи називе фајлова Веб-сајт Наслов плејлисте Ваша преузимања ће бити сачувана као: Присили IPv4 Системска подешавања Учините све везе преко IPv4 Чувај фајлове титлова Дозволи једном Дозволи увек Не дозволи Дозволити преузимање путем мобилних података? Споји више аудио стримова Дозволите да се више аудио стримова споји у један фајл Претрага у преузимањима Претрага Аутоматски преведени титлови Аутоматски преведени титлови за све језике биће доступни у преузимањима. Ови титлови могу бити нетачни и тешки за разумевање. Запамти за следеће преузимање Користи прецизан избор Ниједно Језик титлова за преузимање у аутоматском избору формата, одвојен запетама. Ресетуј Не, хвала Претрага у титловима Следећи језици ће бити додати вашим преференцама за будућа преузимања: Ажурирати језике титлова? Извоз Увоз Увести историју преузимања? Извести историју преузимања? Потпуна резервна копија Тип резервне копије Извоз у Фајл Привремену меморију Увоз из Преузети фајлови неће бити увезени. Мораћете да их поново преузмете ручно Извоз %1$s из историје преузимања. За преузете фајлове и подешавања неће бити направљена резервна копија. Историја преузимања Увезен %1$s у историју преузимања Поново преузми Видео снимак је преузет. Ако ово није очекивано понашање, проверите своју архиву преузимања. Пребаци видео снимке у MKV контејнер ради боље компатибилности Пребаци контејнер видео снимка Укупно %1$d колачића са %2$d веб-сајтова Сваког дана Сваке седмице Сваког месеца Сви језици Плејлиста Унапред подешено Преферирај %1$s Измени унапред подешено Преузмите аутоматски користећи своје преференце формата Задатак је додат у редослед %d видео снимак %d видео снимка %d видео снимака %d аудио снимак %d аудио снимка %d аудио снимака Бирајте између формата, титлова и додатно прилагодите Настави Преузмите најбољи доступни формат Овде ћете пронаћи своја преузимања Додирните дугме за преузимање или поделите линк видео снимка до ове апликације да бисте започели преузимање Преузето Све Изаберите неки од %1$d линкова Редослед преузимања Прикажи фиоку за навигацију Избриши Настави Информације о медијима Пратилац проблема Решавање проблема Наишли сте на грешку? Пре него што пријавите нови проблем, претражите наш алат за праћење проблема. Многи уобичајени проблеми су тамо већ обрађени и документовани. Поправите уобичајене грешке и проверите познате проблеме Додај у %1$s Додај нови линк Сачувани линкови ================================================ FILE: app/src/main/res/values-sv/strings.xml ================================================ Videomapp Spara som ljud Inställningar Allmänt, format, anpassat kommando Ladda ner Länken får inte vara tomt Ladda ner och spara ljud, istället för video Använder den senaste versionen av yt-dlp Hämtar in videoinformation… Tillstånd nekad Nerladdning klar Kunde inte ladda ner fil Ladda ner \"%1$s\" Kunde inte hämta in videoinformation Allmänt Gränssnittsspråk Ange gränssnittets språk En befintlig nedladdningsuppgift är redan igång Klistra in URL Kunde inte matcha URL:en i Urklipp Yt-dlp version Ta bort\? Ta bort \"%1$s\" från din nerladdningshistorik för gott\? Bekräfta Avbryt Nerladdningar Ljud Länk kopierad till urklipp Öppna länken Ta bort Radera fil Version, feedback, automatisk uppdatering Version Leta efter ändringsloggar och nya versioner Senaste utgåvan Video Kontrollerat Krediter Krediter och fri programvara Anpassat kommando Mall för kommando Redigera Börja utföra kommandot Avancerad Detaljerad utgång Skriv ut detaljerade meddelanden vid nedladdning Skärm Mörkt tema, dynamisk färg, språk Mörkt tema Systemet Av Avbryt Konfigurera inställningar före nedladdning Justera denna nedladdning Felrapport kopierad till urklipp Miniatyrbild Klistra in Användningsreferenser för Yt-dlp Konvertera ljudformat Okonverterad Konvertera till %1$s Format Videokvalitet Bästa kvalitet (standard) Begränsa videokvaliteten när flera är närvarande Ej specificerad (standard) Föredraget videoformat Valt format när flera tillhandahålls Videoformat Konvertera Ladda ner Stäng Visa inte igen Användarguide Öppna inställningar Klicka sedan på \"Ladda ner\" efter att ha justerat inställningarna. Ta en titt på nedladdningsinställningarna och se till att du har den senaste versionen av yt-dlp innan du använder den. Ladda ner spellista Ladda ner flera videor från en spellista Standard Ladda ner Meddela om nedladdade filer och framsteg Videolänk Nedladdningen är klar. Tryck för att öppna. Multitrådad nedladdning Ladda ner fler delar av M3U8/MPD-videor parallellt %d trådar skulle användas för att ladda ner DASH/HLS videon samtidigt. Alternativ Kan inte matcha URL från delat innehåll Läser videolänk från delat innehåll… Visa fler åtgärder Meddela om nedladdade filer och framsteg Hämtar information om spellistan… Börja Ogiltigt indexintervall Spara miniatyrbild Spara video miniatyrbild som fil Kunde inte installera den senaste yt-dlp version. Kontrollera att du är ansluten till Internet. Om Klicka här för att installera den senaste yt-dlp versionen Tillbaka Kontrollera GitHub-arkivet och README Kör kommandot yt-dlp med anpassad mall Konfigurera före nedladdning Utmatningssökväg och URL kommer att läggas till av appen. Omkodning av ljudfiler leder till försämrad ljudkvalitet och ökad filstorlek. Klicka på \"Klistra in\" för att hämta videolänk från urklipp. Kontrollera och hantera nedladdningar i appen, inklusive videor och ljudfiler. Kör anpassade kommandon… Ställ in batterianvändningen för denna app på \"Obegränsad\" i systeminställningarna för att ladda ner i bakgrunden. Ytterligare inställningar Nedladdningsanmälan Urval av spellista Hämtar spellista (%1$d/%2$d)… Ljudmapp Sluta Ange hur många videor som ska hämtas från spellistan \"%3$s\" (från %1$d till %2$d). Katalog för nedladdningar Välj plats för nedladdade videor och ljudfiler Spara till underkatalog Spara filer i kataloger namngivna efter dess respektive webbplatser Problem med åtkomst till lagringsutrymme Kataloger annat än Nedladdade filer/ och Dokument/ stödjs inte Batterikonfiguration Avaktivera appens batterioptimering för att aktivera bakgrundsnedladdning Uppdatera yt-dlp ================================================ FILE: app/src/main/res/values-ta/strings.xml ================================================ லிங்க் காலியாக இருக்கக்கூடாது வீடியோவிற்குப் பதிலாக ஆடியோவைப் பதிவிறக்கிச் சேமிக்கவும் ஏற்கனவே ஒரு பதிவிறக்கம் செயல்பாட்டில் உள்ளது உங்கள் பதிவிறக்க வரலாற்றிலிருந்து \"%1$s\" ஐ அகற்றவா\? கிளிப்போர்டில் உள்ள URLஐப் பொருத்த முடியவில்லை சமீபத்திய yt-dlp பதிப்பை நிறுவ முடியவில்லை. நீங்கள் இணையத்துடன் இணைக்கப்பட்டுள்ளீர்களா என்பதை உறுதிப்படுத்தவும். வீடியோ கோப்பு ஆடியோவாக சேமிக்கவும் சிறுபடத்தை சேமிக்கவும் அமைப்புகள் பொது, வடிவம், தனிப்பயன் கட்டளை பதிவிறக்கு வீடியோ சிறுபடத்தை ஃபைலாக சேமிக்கவும் yt-dlp இன் சமீபத்திய பதிப்பைப் பயன்படுத்துகிறது வீடியோவின் தகவலைப் பெறுகிறது… அனுமதி மறுக்கப்பட்டது பதிவிறக்கம் முடிந்தது ஃபைலை பதிவிறக்க முடியவில்லை \"%1$s\" ஐ பதிவிறக்கு வீடியோ தகவலைப் பெற முடியவில்லை பொது ஆப் மொழி ஆப் மொழியை அமைக்கவும் கிளிப்போர்டிலிருந்து URL ஐ ஒட்டவும் Yt-dlpன் பதிப்பு சமீபத்திய yt-dlp பதிப்பை நிறுவ கிளிக் செய்யவும் நீக்கவா\? உறுதிப்படுத்தவும் ரத்து செய் பதிவிறக்கங்கள் ஆடியோ லிங்க் கிளிப்போர்டுக்கு நகலெடுக்கப்பட்டது லிங்க்கை திற நீக்கு கோப்புகளை அழி ஆப்பை பற்றி பதிப்பு காணொளி காட்சி அமைப்பமுறை ரத்து செய் பதிவிறக்குவதற்கு முன் உள்ளமைக்கவும் பதிப்பு, வெளியீடுகள், தானாக மேம்படுத்தல் மாற்ற பதிவுகள் மற்றும் புதிய பதிப்புகளைப் பார்க்கவும் இயக்கவும் பதிவிறக்குவதற்கு முன் விருப்பங்களை உள்ளமைக்கவும் இந்த பதிவிறக்கத்தை சரிசெய்யவும் சிறுபடம் ஒட்டவும் Yt-dlp பயன்பாட்டுக் குறிப்புகள் அவுட்புட் பாதை மற்றும் URL ஆப்ஸால் சேர்க்கப்படும். ஆடியோ வடிவத்தை மாற்றவும் மாற்றப்படாதது %1$s ஆக மாற்றவும் வடிவம் காணொளி தரம் விருப்பமான காணொளி வடிவம் பல வழங்கப்படும் போது விருப்பமான வடிவம் சிறந்த தரம் பல இருக்கும் போது வீடியோ தரத்தை வரம்பிடவும் குறிப்பிடப்படவில்லை (இயல்புநிலை) மூடு அமைப்புகளைத் திறக்கவும் காணொளி வடிவம் மாற்றவும் பயனர் வழிகாட்டி பதிவிறக்கம் மீண்டும் காட்ட வேண்டாம் உங்கள் கிளிப்போர்டிலிருந்து வீடியோ இணைப்பைப் பெற \"Paste\" என்பதைக் கிளிக் செய்யவும். பின்னோக்கி பதிவிறக்கும் போது விரிவான செய்திகளை அச்சிடவும் விரிவான வெளியீடு அணைக்க புகழ் கருப்பு தீம், மாறும் நிறம், மொழிகள் கருப்பு தீம் பிழை அறிக்கை கிளிப்போர்டுக்கு நகலெடுக்கப்பட்டது சமீபத்திய வெளியீடு "GitHub repository ஐயும் README ஐயும் பாறுங்கள்" தனிப்பயன் டெம்ப்ளேட்டுடன் yt-dlp கட்டளையை இயக்கவும் தனிப்பயன் கட்டளை சரிபார்க்கப்பட்டது கடன்கள் மற்றும் இலவச மென்பொருள் மின்-குறியாக்க ஆடியோ கோப்புகள் ஆடியோ தரத்தில் இழப்பு மற்றும் கோப்பு அளவு அதிகரிக்கும். அதன் அமைப்புகளை சரிசெய்த பிறகு \"பதிவிறக்கு\" என்பதைக் கிளிக் செய்யவும். வீடியோக்கள் மற்றும் ஆடியோ கோப்புகள் உட்பட ஆப்ஸ் பதிவிறக்கங்களைச் சரிபார்த்து நிர்வகிக்கவும். பதிவிறக்கம் செய்யப்பட்ட கோப்புகள் மற்றும் முன்னேற்றம் குறித்து தெரிவிக்கவும் வீடியோ இணைப்பு தனிப்பயன் கட்டளைகளை இயக்குகிறது… yt-dlp மேம்படுத்தல் கட்டளை விளக்கம் பிளேலிஸ்ட்டில் இருந்து பல வீடியோக்களைப் பதிவிறக்கவும் பதிவிறக்க அமைப்புகளைப் பார்த்து, அதைப் பயன்படுத்துவதற்கு முன், yt-dlp இன் சமீபத்திய பதிப்பு உங்களிடம் உள்ளதா என்பதை உறுதிப்படுத்திக் கொள்ளுங்கள். இயல்புநிலை பதிவிறக்கம் பிளேலிஸ்ட்டைப் பதிவிறக்கவும் மேம்பட்டது பதிவிறக்கம் முடிந்தது. திறக்க தட்டவும். திருத்த கட்டளையை இயக்கத் தொடங்குங்கள் ஆரம்பி/ஆரம்பம் முடிவு ================================================ FILE: app/src/main/res/values-th/strings.xml ================================================ ดาวน์โหลดและบันทึกเป็นไฟล์เสียงแทนการบันทึกเป็นไฟล์วิดีโอ โฟลเดอร์ของวิดีโอ บันทึกภาพขนาดย่อ การตั้งค่า ดาวน์โหลด ลิงก์ไม่สามารถปล่อยว่างไว้ได้ บันทึกภาพขนาดย่อของวิดีโอเป็นไฟล์แยก กำลังใช้ yt-dlp เวอร์ชั่นล่าสุด บันทึกเป็นไฟล์เสียง ทั่วไป รูปแบบ คำสั่งที่กำหนดเอง ไม่จำกัด บิทเรตต่ำที่สุด คุณภาพเสียง นำเข้า ไตเติ้ล วินาที นาที ล้างคุกกี้ทั้งหมด ไม่มีสื่อที่ดาวน์โหลด เปิดใช้งานฟีเจอร์ทดลอง\? เบต้า รูปแบบเสียง แชร์ เสถียร อัปเดทช่อง อัปเดทอัตโนมัติ เปิดใช้งานการอัปเดทอัตโนมัติ ขอบคุณมาก! ข้อความจากผู้พัฒนา ตกลง นำไปใช้ ละทิ้ง ดูตัวอย่าง เริ่ม จบ คลิปวีดีโอ ฟีเจอร์นี้ไม่สามารถใช้งานได้ สนับสนุนแอปนี้โดยการสนับสนุนบน GitHub การจัดเรียงรูปแบบ จำกัดอัตราการส่งถ่ายบิทเรตของเสียงเมื่อมีคุณภาพหลายชนิดอยู่ ผู้สนับสนุน ความคิดเห็น ผู้สนับสนุน เก็บไฟล์ชั่วคราวในไดเรกทอรีภายใน เปลี่ยนชื่อ รูปแบบเสียงที่ชอบ รูปแบบที่ต้องการเมื่อมีวีดีโอหลายรายการ แปลงรูปแบบไฟล์เสียง ลบคุกกี้ทั้งหมดที่เก็บไว้ในแอปอย่างถาวรหรือไม่\? %1$d ถูกเลือก เลือกทั้งหมด เลือกวิดีโอที่จะดาวน์โหลดจากเพลย์ลิสต์ \"%1$s\" ทำคลิปวิดีโอในหน้าเลือกรูปแบบ แปลงเป็น %1$s แปลง โปรดตั้งค่าการใช้งานแบตเตอรี่ของแอพฯ นี้เป็น \"ไม่จำกัด\" ในการตั้งค่าระบบเพื่อดาวน์โหลดในพื้นหลัง แจ้งเตือนไฟล์ที่ดาวน์โหลดแล้วและความคืบหน้าของการดาวน์โหลด ไดเรกทอรีดาวน์โหลด โฟลเดอร์ไฟล์เสียง การกำหนดค่าแบตเตอรี่ ไม่รองรับไดเรกทอรีที่อยู่นอก Download/ และ Documents/ มีปัญหาการเรียกใช้สิทธิ์การเข้าถึงที่เก็บข้อมูล บันทึกไฟล์ในโฟลเดอร์ที่ตั้งชื่อตามที่มาที่เกี่ยวข้อง บันทึกไปยังไดเรกทอรีย่อย Issue ใน GitHub ยกเลิกงานดาวน์โหลดแล้ว ข้อผิดพลาดที่ไม่รู้จัก Seal กำลังดาวน์โหลด… ละเว้นการเพิ่มประสิทธิภาพแบตเตอรี่สำหรับแอพฯ นี้เพื่อดาวน์โหลดในพื้นหลัง กำลังดาวน์โหลด… ­ข้อมูลถูกคัดลอกไปยังคลิปบอร์ดแล้ว ยกเลิกแล้ว กำลังดาวน์โหลด เสร็จสมบูรณ์ อยู่ในคิว คัดลอกรายงาน ไฟล์วีดีโอจำนวน %1$d รายการ ไฟล์เสียงจำนวน %2$d รายการ จำนวนงานดาวน์โหลด %1$d รายการ ขนาดไฟล์วิดีโอ ความละเอียดของวิดีโอ ตรวจสอบเวอร์ชันล่าสุดบน GitHub โดยอัตโนมัติ จำกัดเรท เครือข่าย ใช้คุกกี้ที่จัดรูปแบบโดย Netscape สำหรับการดาวน์โหลด ไม่สามารถอัพเดทเป็นเวอร์ชันล่าสุดได้ เวอร์ชั่นปัจจุบันเป็นเวอร์ชั่นล่าสุดแล้ว คุณภาพต่ำสุด ซับไตเติ้ลอาจจะถูกจัดเวลาผิดเมื่อลบช่วงของ SponsorBlock กำลังดาวน์โหลดเพลย์ลิสต์ (%1$d/%2$d) … การใช้คำสั่งที่กำหนดเอง เสียง ยกเลิก ต้องการลบหรอไม่? ไม่สามารถจับคู่ URL ในคลิปบอร์ดได้ ภาษาที่แสดงในแอพพลิเคชั่น ไม่สามารถดึงข้อมูลของวิดีโอได้ รุ่นล่าสุด ค้นหาบันทึกการเปลี่ยนแปลงและเวอร์ชันใหม่ ๆ เทมเพลตคำสั่ง เลือกแล้ว คำสั่งที่กำหนดเอง เอาต์พุตโดยละเอียด รันคำสั่ง yt-dlp จากเทมเพลตที่กำหนดเอง แสดงข้อความแบบละเอียดเมื่อกำลังดาวน์โหลด การแสดงผล ปิด ธีมมืด สีไดนามิก ภาษาที่แสดง ธีมมืด วาง การอ้างอิงการใช้งานของ Yt-dlp ภาพขนาดย่อ รายงานข้อผิดพลาดถูกคัดลอกไปยังคลิปบอร์ดแล้ว กำหนดค่าก่อนดาวน์โหลด ยกเลิก ไม่ได้แปลง รูปแบบไฟล์วีดีโอ คุณภาพวีดีโอ ไม่ได้ระบุ (ค่าเริ่มต้น) จำกัดคุณภาพวิดีโอเมื่อมีวีดีโอหลายรายการ คุณภาพดีที่สุด ไม่ต้องแสดงอีก ปิด ดาวน์โหลด ลิงก์วีดีโอ ดาวน์โหลด ค่าเริ่มต้น ดาวน์โหลดหลายวิดีโอจากเพลย์ลิสต์ ดาวน์โหลดเพลย์ลิสต์ ดาวน์โหลดแบบมัลติเธรด กำลังรันคำสั่งที่กำหนดเอง… ดาวน์โหลดเสร็จสิ้น แตะเพื่อเปิด กำลังอ่านลิงก์วิดีโอจากเนื้อหาที่แชร์… การตั้งค่าเพิ่มเติม ไม่สามารถจับคู่ URL จากเนื้อหาที่แชร์มาได้ เริ่ม กำหนดช่วงของวิดีโอที่จะดาวน์โหลดจากเพลย์ลิสต์ \"%3$s\" (%1$d ถึง %2$d) ฝังซับไตเติ้ล แก้ไขและจัดการเทมเพลตคำสั่ง การเลือกแม่แบบ ลบ \"%1$s\" ออกจากเทมเพลตคำสั่งอย่างถาวรหรือไม่? ต้องการลบ? ป้าย ข้อผิดพลาด กำลังดึงข้อมูล เริ่มใหม่ เปิดไฟล์ ส่งออกเทมเพลตแล้ว จำนวน %1$d รายการ นำเข้าเทมเพลตแล้ว จำนวน %1$d รายการ นำเข้าจากคลิปบอร์ด ส่งออกไปยังคลิปบอร์ด ตรวจสอบการอัพเดท ระบุหมวดหมู่ของ SponsorBlock ที่จะถูกลบหรือเพิ่มในไฟล์วิดีโอ ล้างไฟล์ชั่วคราว ไฟล์ชั่วคราวสามารถใช้ในการดำเนินการดาวน์โหลดต่อหลังจากที่ถูกยกเลิกแล้วได้ คุณแน่ใจหรือว่าต้องการลบไฟล์ทั้งหมดนี้หรือไม่?\n\nคุณสามารถดูไฟล์เหล่านี้ใน %1$s ลบไฟล์ชั่วคราวทั้งหมด %1$d รายการ ลบไฟล์ชั่วคราวทั้งหมดจากไดเรกทอรีขั่วคราว ดาวน์โหลดโดยใช้เครือข่ายเซลลูลาร์ ใช้สีจากวอลเปเปอร์ในธีมของแอป ปิดประวัติการดาวน์โหลด ไม่ระบุตัวตน ไฟล์นี้ไม่สามารถใช้งานได้อีกต่อไป การดาวน์โหลดด้วยเครือข่ายเซลลูลาร์ถูกปิดการใช้งานตามการตั้งค่าของคุณ เข้าใจแล้ว เปลี่ยนไปใช้ GitHub builds จบ เปิด ระบบ เครดิตและซอฟต์แวร์ที่เกี่ยวข้อง วีดีโอ เกี่ยวกับ ลบ เปิดลิงก์ ช่อง Telegram ตรวจสอบ repository บน GitHub และไฟล์ README เวอร์ชั่นของ Yt-dlp ลบทั้งหมด %1$d รายการออกจากประวัติการดาวน์โหลดของคุณอย่างถาวร? วาง URL เมื่อใช้คำสั่งที่กำหนดเอง บางตัวเลือกจะไม่สามารถใช้ได้ กำลังดึงข้อมูลของเพลย์ลิสต์… คุกกี้ แนะนำ หลังจากที่คุณปรับการตั้งค่าแล้ว ให้คลิกที่ \"ดาวน์โหลด\" คู่มือผู้ใช้ ลบหรือบันทึกเซกเมนต์ในวิดีโอด้วย SponsorBlock API รูปแบบ วิดีโอ (ไม่มีเสียง) ย้อนกลับ อัพเดท ข้อมูลที่ป้อนไม่ถูกต้อง ดาวน์โหลด \"%1$s\" เครดิต รายการดาวน์โหลด เปิดการตั้งค่า ลบไฟล์ แปลภาษา เริ่มดำเนินงานตามคำสั่ง เส้นทางขาออกและ URL จะถูกเพิ่มโดยแอพพลิเคชั่น การเลือกรูปแบบ ตัวเลือก อนุญาตให้ดาวน์โหลดสื่อเมื่อเชื่อมต่อกับเครือข่ายที่มีการคิดค่าบริการ มันทำงานอย่างไร\? เก็บดาวน์โหลดในไดเรกทอรีที่ซ่อนอยู่ ไม่สามารถดาวน์โหลดไฟล์ได้ การดาวน์โหลดเสร็จสิ้น ตั้งค่าภาษาที่แสดงในแอพพลิเคชั่น ปรับแต่งการดาวน์โหลดนี้ การดาวน์โหลดที่มีอยู่แล้วกำลังทำงานอยู่ การแจ้งเตือนการดาวน์โหลด ลองดูที่การตั้งค่าการดาวน์โหลด ตรวจสอบว่าคุณมี yt-dlp เวอร์ชันล่าสุดของก่อนที่จะใช้งาน ตรวจสอบและจัดการการดาวน์โหลดภายในแอพฯ รวมถึงไฟล์วิดีโอและไฟล์เสียง รูปแบบวิดีโอที่ต้องการ ลบรายการสำหรับ \"%1$s\" หรือไม่? โดยคุกกี้ที่เก็บไว้สำหรับเว็บไซต์นี้จะไม่ถูกลบไปด้วย ใช้คุกกี้ ดาวน์โหลดคำบรรยายที่สร้างขึ้นอัตโนมัติ สร้างคุกกี้ใหม่ ภาษา, ซับไตเติ้ลฝัง, คำบรรยายอัตโนมัติ การดาวน์โหลดจากบางเว็บไซต์จำเป็นต้องระบุข้อมูลการรับรองความถูกต้องของบัญชี คลิก \"สร้างคุกกี้ใหม่\" แล้วใส่ URL ของเว็บไซต์และล็อกอินด้วยบัญชีของคุณในหน้าเบราว์เซอร์ แอปจะสร้างคุกกี้สำหรับคุณ ตัวอย่างข้อความของผู้สร้างวิดีโอ เลือกรูปแบบที่จะดาวน์โหลดก่อนที่จะเริ่มดาวน์โหลด ไม่แสดงภาพขนาดย่อระหว่างการดาวน์โหลด จำกัดความเร็วในการดาวน์โหลดสูงสุด รูปแบบไฟล์, คุณภาพวิดีโอ, ซับไตเติ้ล ธีมที่มีความคมชัดสูงและมืด หมวดหมู่ของ SponsorBlock ส่งรายงานข้อบกพร่องหรือร้องขอฟีเจอร์ คำนำหน้า เทมเพลตใหม่ ฝังซับไตเติ้ลในไฟล์วิดีโอหากมีมาให้ ช่วยแปลภาษาของแอพฯ นี้บน Hosted Weblate เลือกตำแหน่งที่จะเก็บไฟล์วิดีโอและเสียง ช่วงดัชนีไม่ถูกต้อง การเลือกเพลย์ลิสต์ แจ้งเตือนไฟล์ที่ดาวน์โหลดแล้วและความคืบหน้าของการดาวน์โหลด แสดงการกระทำเพิ่มเติม %d เธรด จะถูกใช้เพื่อดาวน์โหลดวิดีโอ DASH/HLS แบบพร้อมกัน ดาวน์โหลดส่วนอื่น ๆ ของวิดีโอ M3U8/MPD พร้อมกัน คลิก \"วาง\" เพื่อวางลิงค์วิดีโอจากคลิปบอร์ดของคุณ การเข้ารหัสไฟล์เสียงอีกครั้งจะทำให้ไฟล์เสียงสูญเสียคุณภาพและขนาดไฟล์จะเพิ่มมากขึ้น ขั้นสูง ต้องการลบ \"%1$s\" ออกจากประวัติการดาวน์โหลดของคุณอย่างถาวร? ออโต้อัปเดทไม่สามารถใช้ได้สำหรับ %1$s นี้ ถ้าคุณไม่ได้ติดตั้ง %1$s บนอุปกรณ์ของคุณหรือต้องการดูฟีเจอร์ใหม่ที่กำลังจะมาใน Seal โปรดพิจารณา %2$s สำหรับการฝังซับไทเทิลฝังแยก วิดีโอจะถูกรีมักซ์เข้ากับคอนเทนเนอร์ mkv คุณสามารถใช้ VLC Media Player หรือแอปพลิเคชั่นอื่น ๆ ที่รองรับเพื่อเล่นวีดีโอที่มีซับไทเทิลฝังแยก กำหนดตัวเลือกก่อนการดาวน์โหลด เวอร์ชั่น เวอร์ชั่น การตอบรับ การอัปเดตอัตโนมัติ คลิกเพื่อติดตั้ง yt-dlp เวอร์ชั่นล่าสุด การดาวน์โหลดที่ใช้คุณลักษณะนี้จะถูกมอบหมายให้ FFmpeg เพื่อดาวน์โหลดส่วนที่เลือกของวิดีโอ คุณลักษณะนี้ยังเป็นการทดลองและการตัดจะไม่เป็นแน่นอน ไม่ทุกรูปแบบสนับสนุนคุณลักษณะนี้และคุณอาจพบความเร็วดาวน์โหลดที่ช้าลง แก้ไขทางลัดที่กำหนดเองที่สามารถใช้ในการสร้างแม่แบบคำสั่ง คัดลอกลิงก์ไปยังคลิปบอร์ดแล้ว ยืนยัน ทั่วไป แก้ไข ไม่มีงานคำสั่งที่กำหนดเอง คัดลอกบันทึก สิทธิ์ของแอพพลิเคชั่นถูกปฏิเสธ กำลังดึงข้อมูลของวีดีโอ… ไม่สามารถติดตั้ง yt-dlp เวอร์ชั่นล่าสุดได้ โปรดตรวจสอบให้แน่ใจว่าคุณได้เชื่อมต่ออินเทอร์เน็ตแล้ว ดาวน์โหลดด่วน คำบรรยายอัตโนมัติ โฟลเดอร์การ์ด SD ไดเรกทอรีส่วนตัว ความเป็นส่วนตัว ปิดการแสดงตัวอย่าง จำกัดเรท, ตัวดาวน์โหลด, คุกกี้ ไม่พร้อมใช้งาน เรทสูงสุด ใช้ aria2c เป็นตัวดาวน์โหลดภายนอก เพิ่มเมื่อเร็วๆ นี้ คัดลอกลิงค์ บันทึก แสดงบันทึก ทางลัด เพิ่ม แก้ไขทางลัด ล้าง ภาษาซับไตเติ้ล ดาวน์โหลดซับไตเติ้ล ซับไตเติ้ล ตัวอย่างข้อความของไตเติ้ล กำลังรันงาน Seal จะยังคงเป็นซอร์ตฟรีและเปิดกว้างสำหรับทุกคน ถ้าคุณชอบมัน กรุณาพิจารณาสนับสนุนฉันใน GitHub! จัดเรียงรูปแบบด้วยตัวเลือก -S ของ yt-dlp ติดตั้งรุ่นก่อนวางจำหน่ายเพื่อดูลักษณะใหม่และการเปลี่ยนแปลง \n \nอาจมีความไม่สมดุลในรุ่นนี้ โปรดอย่าลังเลใจกับเราในการรีบรีบทักทายถ้าคุณพบปัญหาใดๆ เพื่อช่วยให้เราปรับปรุงแอปในอนาคต %.2f GB %.2f MB อัพเดต yt-dlp เมทริกซ์สเปซ โหมดเลือกหลายรายการ ครอบตัดรูปภาพ yt-dlp คือเครื่องมือ command line ประสิทธิภาพสูงสำหรับดาวน์โหลดวีดีโอ Seal ทำให้การใช้งาน yt-dlp ง่ายขึ้นด้วยอินเทอร์เฟซที่เรียบง่าย, พรีเซ็ตคำสั่งทั่วไป และฟีเจอร์เพิ่มเติมอื่นๆ \n \nสำหรับการใช้งานขั้นสูงของ yt-dlp. Seal ให้คุณสามารถสร้าง, บันทึก และใช้เทมเพลตคำสั่งที่กำเองได้โดยตรง เหมือนเทอมินอลเลย \n \nเมื่อคุณกำลังใช้คำสั่งที่กำหนดเอง ตัวเลือกและฟีเจอร์ที่เกี่ยวข้องกับส่วนติดต่อผู้ใช้งานจะถูกปิดใช้งานด้วย เวอร์ชั่นของ Yt-dlp, การแจ้งเตือน, เพลย์ลิสต์ สีไดนามิก เริ่มต้น ครอบตัดรูปภาพที่ฝังให้เป็นรูปสี่เหลี่ยม นำคำสั่ง %1$s ออกจากเทมเพลตคำสั่งอย่างถาวร? แตะเพื่อเปิดเว็ปเพจเพื่อสร้างคุกกี้: แก้ไขไฟล์ บันทึก ฝังข้อมูลเมต้า ฝังข้อมูลเมต้าและภาพขนาดย่อลงในไฟล์เสียง อนุญาตครั้งเดียว ใช้การจัดเรียงรูปแบบ แพลตฟอร์มสตรีมมิ่งวิดีโอส่วนใหญ่จะส่งเสียงและวิดีโอแยกจากกัน คุณสามารถเลือกและรวมรูปแบบเฉพาะเสียงเข้ากับรูปแบบเฉพาะวิดีโอให้เป็นวิดีโอเดียวได้ อนุญาตทุกครั้ง ให้สามารถดาวน์โหลดโดยใช้ข้อมูลมือถือ? วีดีโอถูกดาวน์โหลดแล้ว หากนี้ไม่ใช่สิ่งที่คาดไว้ โปรดตรวจสอบในรายการจัดเก็บดาวน์โหลด รวมสตรีมเสียง รายการดาวน์โหลดของคุณจะถูกบันทึกเป็น: ส่วนของของ User-Agent แปลงซับไทเทิ้ลเป็นรูปแบบอื่น แยกวีดิโอ คัดลอกและออก แก้ไข “%1$s” ขยาย เพิ่มงานดาวน์โหลด เก็บไฟล์ซับไทเทิล ใช้พร็อกซีสำหรับการเชื่อมต่ออินเทอร์เน็ต วีดีโอจะถูกแยกเป็น %1$d ตอน อุ้ย! มีบางอย่างผิดพลาด เข้ากันได้ พร็อกซี รับการแจ้งเตือนหรือไม่? แอปพลิเคชั่นต้องการสิทธิ์ในการแจ้งเตือนเพื่อส่งการแจ้งเตือนสถานะและความคืบหน้าของการดาวน์โหลด แตะเพื่อกำหนดไดเรกทอรี ปิดใช้งาน เลือกรูปแบบ AV1, VP9 หรือ H.265 สำหรับการรับชมบนแอปพลิเคชั่นที่รองรับ ชนิดของการดาวน์โหลด เลือกรูปแบบ MP4(H.264) สำหรับการแชร์ไปที่แอปพลิเคชั่นอื่น ๆ รีมักซ์วีดีโอลงในคอนเทนเนอร์ MKV สำหรับความเข้ากันที่ดีขึ้น รีมักซ์คอนเทนเนอรวีดีโอ จำเป็น ดาวโหลดวีดีโอจาก URL แสดงรายการทั้งหมด %1$d รายการ สำรองแบบเต็ม ส่งออกไปยัง ชนิดของการสำรอง คลิปบอร์ด ไฟล์ที่ดาวน์โหลดแล้วจะไม่ถูกนำเข้า คุณต้องดาวน์โหลดด้วยตัวเอง ส่งออกเป็นไฟล์ คำสั่งต่าง ๆ ตัวเลือกของรูปแบบ เรียนรู้เพิ่มเติม กำหนดเอง อัตโนมัติ ไม่รู้จัก จำกัดชื่อไฟล์ จำกัดชื่อไฟล์ให้มีตัวอักษรเฉพาะ สำหรับความเข้ากัน เว็บไซต์ ชื่อเพลย์ลิสต์ ปิดใช้งานแล้ว ที่เปิดโฟลเดอร์ ระบุไดเรกทอรีขาออกเมื่อใช้คำสั่งที่กำหนดเอง คุณภาพ ไดเรกทอรีของคำสั่งที่กำหนดเอง แปลงซับไทเทิล ขาออกของเทมเพลต พรีเซ็ต ระบุเทมเพลตสำหรับชื่อไฟล์ขาออก %1$d คุกกี้จากเว็บไซต์ทั้งหมด %2$d เว็บไซต์ ภาษาที่เลือกนี้จะถูกเพิ่มเป็นภาษาที่คุณชอบเพื่อใช้ในการดาวน์โหลดครั้งต่อ ๆ ไปในอนาคต: ดาวน์โหลดใหม่ กำลังส่งออก %1$s จากประวัติการดาวน์โหลด ไฟล์ที่ดาวน์โหลดแล้วและการตั้งค่าต่าง ๆ จะไม่ได้รับการสำรองข้อมูล นำเข้า %1$s สู่ประวัติการดาวน์โหลดแล้ว จัดเก็บดาวน์โหลด อนุญาตให้รวมสตรีมเสียงเข้าไปในไฟล์เดียว ซับไทเทิลที่แปลอัตโนมัติแล้ว ซับไทเทิลที่แปลอัตโนมัติแล้วสำหรับทุกภาษาจะสามารถค้นพบได้ในรายการดาวน์โหลด ซับไทเทิลเหล่านี้อาจไม่แม่นยำหรือเข้าใจยากจากการแปลอัตโนมัติ %d รายการ จดจำสำหรับหารดาวน์โหลดครั้งหน้า การตั้งค่าระบบ ภาษาของซับไทเทิลที่จะดาวน์โหลดในโหมด“เลือกรูปแบบอัตโนมัติ” แยกแต่ละภาษาที่ต้องการด้วยเครื่องหมายจุลภาค อินเทอร์เฟซและการใช้งาน ใช้การเลือกครั้งก่อนหน้านี้ ไม่มี รูปลักษณ์และความรู้สึก รีเซ็ต ค้นหาในซับไทเทิล ไม่เป็นไร ขอบคุณ อัพเดตภาษาของซับไทเทิล? ส่งออก นำเข้า ไฟล์ นำเข้าจาก ส่งออกไปที่ประวัติการดาวน์โหลด? นำเข้าประวัติการดาวน์โหลด? ประวัติการดาวน์โหลด บันทึกไอดีของวีดีโอที่ดาวน์โหลดแล้วลงในส่วยที่จัดเก็บเพื่อเลี่ยงการดาวน์โหลดซ้ำซ้อน ล้างรายการจัดเก็บดาวน์โหลด? ลบ %1$s ในที่จัดเก็บไฟล์ถาวรหรือไม่? บังคับใช้ IPv4 บังคับให้ทุกการเชื่อมต่อให้ใช้ IPv4 ทุกวัน ทุกสัปดาห์ ทุก ๆ เดือน ค้นหาในรายการดาวน์โหลด ค้นหา ไม่อนุญาต เพิ่มงานเข้าคิวแล้ว ทั้งหมด ภาษาทั้งหมด %1$s หากเป็นไปได้ ดาวน์โหลดรูปแบบไฟล์ที่ดีที่สุดที่มี แตะปุ่มดาวน์โหลดหรือแชร์ลิงก์วิดีโอมายังแอปนี้เพื่อเริ่มการดาวน์โหลด ดาวน์โหลดแล้ว เลือก %1$d จากลิงก์ ทำต่อ คิวดาวน์โหลด พรีเซ็ต เพลย์ลิสต์ แก้ไขพรีเซต เลือกรูปแบบไฟล์ คำบรรยาย และการปรับแต่งเพิ่มเติม ดาวน์โหลดโดยอัตโนมัติโดยเลือกจากรูปแบบไฟล์ที่ตั้งไว้ การดาวน์โหลดของคุณอยู่ที่นี่ การแก้ไขปัญหา ตัวติดตามปัญหา แก้ไขข้อผิดพลาดทั่วไปและตรวจสอบดูปัญหาที่ทราบ พบข้อผิดพลาด? ก่อนที่จะรายงานปัญหาใหม่ โปรดค้นหาบนตัวติดตามปัญหาของเรา ปัญหาทั่วไปหลายประการได้รับการแก้ไขและบันทึกไว้แล้ว แสดงการนำทางแบบลิ้นชัก ทำต่อ ลบ ข้อมูลสื่อ %d ไฟล์วิดีโอ %d ไฟล์เสียง ================================================ FILE: app/src/main/res/values-tr/strings.xml ================================================ Ses olarak kaydet İzin reddedildi Video bilgileri alınamadı Açık Bağlantı boş olamaz Video yerine ses olarak indirin ve kaydedin Video küçük resmini dosya olarak kaydet Genel, biçim, özel komut yt-dlp\'nin son versiyonu yüklenemedi. Lütfen internete bağlandığınızdan emin olun. Ayarlar İndir yt-dlp\'nin en son versiyonu kullanılıyor Küçük resmi kaydet Video bilgileri alınıyor… İndirme tamamlandı Dosya indirilemedi \"%1$s\" dosyasını indir Genel Arayüz Dili Arayüz dilini ayarlayın Mevcut bir indirme görevi zaten çalışıyor URL\'yi yapıştır Panodaki URL eşleştirilemedi Yt-dlp sürümü Kaldırılsın mı\? Bağlantıyı aç Geri Son sürüm Kontrol edildi yt-dlp komutunu özel şablonla çalıştır İndirilirken detaylı mesajlar yazdır Arayüz Koyu tema, dinamik renk, diller Koyu tema İndirmeden önce düzenleyin İndirmeden önce ayarlama tercihleri Bu indirmeyi ayarla Hata raporu panoya kopyalandı Küçük resim Orijinal Biçim Çıktı yolu ve URL uygulama tarafından eklenecektir. %1$s biçimine dönüştür Birden fazla sağlandığında tercih edilen biçim Dönüştür İndir Kullanıcı rehberi Ayarları aç Panonuzdan video bağlantısını almak için \"Yapıştır\"a tıklayın. Ardından düzenlemeleri yaptıktan sonra \"İndir\"e tıklayın. Videolar ve ses dosyaları dahil uygulama içi indirmeleri kontrol edin ve yönetin. Özel komutlar çalıştırılıyor… Arka planda indirmek için lütfen bu uygulamanın pil kullanımını sistem ayarlarında \"Kısıtlama yok\" olarak ayarlayın. M3U8/MPD videolarının daha fazla bölümünü paralel olarak indirin Seçenekler %d sanal çekirdek DASH/HLS yerel videosunu aynı anda indirmek için kullanılacaktır. Paylaşılan içerikten URL eşleştirilemiyor Paylaşılan içerikten video bağlantısı okunuyor… Oynatma listesi bilgisi alınıyor… Oynatma listesi seçimi Başlangıç Videoların ve ses dosyalarının nerede saklanacağını seçin Depolama izni sorunu Pil yapılandırması Seal indiriyor… Bilinmeyen hata Hosted Weblate\'de bu uygulamanın çevrilmesine yardımcı olun Video klasörü İndirilenler/ ve Belgeler/ dışındaki dizinler desteklenmez Alt dizine kaydet Kapalı Ses dosyalarının yeniden kodlanması ses kalitesinde kayba ve dosya boyutunda artışa neden olacaktır. Video kalitesi Belirtilmemiş (varsayılan) İptal et \"%1$s\" indirme geçmişinizden tamamen kaldırılsın mı? Onayla Hakkında Sürüm, geri bildirim, otomatik güncelleme Sürüm GitHub deposunu ve README dosyasını kontrol edin Video İndirilenler En son yt-dlp sürümünü indirmek için tıklayın Ses Kaldır İndir Bağlantı panoya kopyalandı Dosyayı sil Emeği geçenler ve özgür yazılımlar Detaylı çıktı Ses biçimini dönüştür Değişim kaydına ve yeni sürümlere bakın Özel komut Düzenle Gelişmiş Sistem İptal et Oynatma listesini indir Emeği geçenler Komut şablonu Komut çalıştırılıyor Yapıştır Yt-dlp kullanım örnekleri İndirme tamamlandı. Açmak için dokunun. Varsayılan Bir oynatma listesinden birden fazla video indir Çoklu indirme Ek ayarlar İndirme bildirimi İndirilen dosyaları ve ilerlemeyi bildir En iyi kalite Tercih edilen video biçimi İndirilen dosyaları ve ilerlemeyi bildir Video bağlantısı Geçersiz dizin aralığı Daha fazla işlem göster Birden fazla olduğunda video kalitesini sınırlayın Video biçimi Oynatma listesi indiriliyor (%1$d/%2$d)… Kapat İndirme ayarlarına bir göz atın ve kullanmadan önce yt-dlp\'nin en son sürümüne sahip olduğunuzdan emin olun. Sonuç İndirme dizini Dosyaları ilgili alanlar olarak adlandırılan klasörlere kaydedin Tekrar gösterme Ses dosyası Bu uygulamanın arka planda indirilmesi için pil optimizasyonunu yoksay Oynatma listesinden indirilecek video aralığını belirtin \"%3$s\" (%1$d ile %2$d arası). Çeviri Önek Alt yazıları göm Yeni şablon Etiket Kaldırılsın mı\? \"%1$s\" komut şablonundan temelli kaldırılsın mı\? Şablon seçimi Komut şablonlarını düzenle ve yönet İndirme devam ediyor… Dosyayı aç Yeniden başlat Sıraya alındı Tamamlandı Bilgi alınıyor Hata Bağlantıyı kopyala Raporu kopyala İptal edildi İndiriliyor İndirme işlemi iptal edildi GitHub sorunu Bilgi panoya kopyalandı Güncellemeleri denetle GitHub\'da en son sürümü otomatik olarak kontrol et Mevcut sürüm günceldir Güncelle Harici indirici olarak aria2c kullanın İndirmeler için Netscape biçiminde çerezleri kullanın Silinen %1$d geçici dosya Tüm geçici dosyaları dahili dizinden silin Geçici dosyaları temizleyin İptal edilen indirme işlemlerine devam etmek için geçici dosyalar kullanılabilir. Bu dosyaların tümünü sildiğinizden emin misiniz? \n \n Bu dosyalara %1$s üzerinden erişebilirsiniz Video çözünürlüğü Video dosyası boyutu Varsa alt yazıları videolara gömün Dışa aktarılan %1$d şablon(lar)ı İçe aktarılan %1$d şablon(lar)ı %1$d İndirme görevleri %1$d öge indirme geçmişinizden tamamen kaldırılsın mı\? Video dosyasında kaldırılacak veya işaretlenecek SponsorBlock kategorilerini belirtin SponsorBlock API ile videolardaki bölümleri kaldırın veya işaretleyin Hata raporu veya özellik isteği için bir konu oluşturun Panoya aktar Panodan aktar Son Eklenenler SponsorBlock kategorileri %1$d video, %2$d ses dosyası En son sürüme güncellenemedi Çoklu seçim modu Gizli Dinamik renk Uygulama tema rengini duvar kağıtlarından uygula İndirme geçmişini devre dışı bırak Hücresel ağ ile indirme, ayarlarınıza göre devre dışı bırakıldı Özel dizin Yüksek kontrastlı koyu tema Geçersiz giriş En düşük kalite Kullanım dışı Dosya biçimi, video kalitesi, alt yazılar Yt-dlp sürümü, bildirim, çalma listesi Hız sınırı, indirici, çerezler Önizlemeyi devre dışı bırak İndirme sırasında küçük resimler görüntülenmiyor Gizlilik Özel komut kullan İndirilenleri gizli dizinde sakla Resmi kırp Hücresel kullanarak indir Tarifeli ağlara bağlanıldığında medya indirmelerine izin ver Bu dosya artık mevcut değil Maksimum hız Azami indirme hızını sınırla Hız sınırı \"%1$s\" oynatma listesinden indirilecek videoları seç Hepsini seç %1$d seçildi Gömülü resmi kareye kırp İndirmeye başlamadan önce indirilecek biçimi seçin Video (ses yok) Önerilen Biçim seçimi Yeni çerezler oluştur Matrix alanı Çerezler Çerezleri kullan \"%1$s\" için bu girdi kaldırılsın mı? Bu site için saklanan çerezlerin silinmeyeceğini lütfen unutmayın. Bazı seçenekler özel komut kullanırken kullanılamaz Nasıl çalışıyor\? Telegram kanalı Bazı sitelerden indirmek için hesap doğrulaması gerekir. \"Yeni çerezler üret\"e tıkla, sitenin URL\'sini gir ve sonra tarayıcı sayfasından hesabına gir, uygulama senin için bir tane oluşturacak. Tercih edilen ses kalitesi İçe aktar Başlık Yeniden adlandır saniye İndirilmiş medya yok Beta Deneysel özelliği etkinleştir\? Ses biçimi Biçim seçim sayfasında video klipler oluşturun Paylaş Stabil Önizleme Günlük Otomatik oluşturulmuş alt yazıları indir Alt yazı dilleri Komut şablonları oluşturmak için kullanılabilecek özel kısayolları düzenleyin. SponsorBlock bölümleri kaldırılırken alt yazılar yanlış olabilir. Limitsiz Yeni özellikleri ve değişiklikleri önizlemek için yayın öncesi yapıları yükleyin. \n \nBu sürümlerde bazı istikrarsızlıklar olacaktır, bu nedenle herhangi bir sorunla karşılaşırsanız, uygulamayı gelecek için iyileştirmemize yardımcı olacak geri bildirimde bulunmaktan çekinmeyin. Güncelleme kanalı Otomatik güncelleme yt-dlp\'nin -S seçeneğiyle biçimleri sırala Bu özelliği kullanan indirmeler, videonun seçilen bölümlerini indirmesi için FFmpeg\'e yetkilendirilecektir, bu özellik henüz deneyseldir ve kesme tam olarak doğru olmayacaktır, tüm biçimler bu özelliği desteklemez ve daha düşük indirme hızlarıyla karşılaşabilirsiniz. dakika Tüm çerezleri temizle Çoğu video akış platformu, ses ve videoyu ayrı sunar, yalnızca ses biçimini yalnızca video biçimiyle tek bir videoda seçebilir ve birleştirebilirsiniz. Uygula At Video kliple Başlat Bitir SD kart dosyası Otomatik alt yazılar Hızlı indir Video başlığı örnek metni Video yaratıcı örnek metni Alt başlık Alt yazıları indir Diller, gömülü alt yazılar, otomatik alt yazılar Günlüğü kopyala Temizle Kısayolları özelleştir Ekle Kısayollar Çalışan görevler Günlüğü göster Alt yazıları gömmek için videolar mkv konteynerine yeniden düzenlenecektir. Alt yazılı videoları izlemek için VLC Media Player\'ı veya diğer uyumlu uygulamaları kullanabilirsiniz. %.2f MB %.2f GB Otomatik güncellemeyi etkinleştir En az bit hızı Ses kalitesi Birden fazla nitelik mevcut olduğunda ses bit hızını sınırlayın Biçim sıralaması Uygulamada saklanan tüm çerezler kalıcı olarak silinsin mi\? Geçici dosyaları dahili dizinde saklayın Sponsor GitHub\'da sponsor olarak bu uygulamayı destekleyin Seal her zaman ücretsiz ve herkes için açık kaynak olacaktır. Beğendiyseniz, lütfen GitHub\'da bana sponsor olmayı düşünün! Geri bildirim Sponsorlar Geliştiriciden Mesaj Çok teşekkür ederim! Tamam. %1$s yapıları için otomatik güncelleme mevcut değildir. Cihazınızda %1$s yüklü değilse veya Seal\'de gelecek yeni özellikleri önizlemek istiyorsanız, lütfen %2$s\'i düşünün. GitHub derlemelerine geçiş Anladım Özellik mevcut değil Alt yazıları dönüştür Alt yazıları başka bir biçime dönüştürün Videoyu böl Video %1$d bölümlere ayrılacak Oops! Bir şeyler yanlış gitti Kopyala ve çık Özel komut görevi yok Videoları URL\'den indirin Genişlet Yeni indirme görevi Başla \"%1$s\"i düzenle %1$s komut şablonlarından tamamen kaldırılsın mı\? %d öge %d ögeler Yeni çerezler oluşturmak amacıyla web sayfasını açmak için dokunun: yt-dlp\'yi güncelleyin Bildirimler etkinleştirilsin mi\? Dizini ayarlamak için dokunun Kullanıcı Aracısı başlığı Proxy İnternet bağlantıları için proxy kullanın Eski Kalite Uygulamanın, indirme durumu ve ilerleme durumu hakkında bildirim gönderebilmesi için izninize ihtiyacı var. Özel komut dizini Devre dışı bırak Devre dışı bırakıldı Klasör seçici Özel komutları kullanırken çıktı dizinini belirtin Diğer uygulamalarla paylaşmak için MP4(H.264) biçimlerini tercih edin Uyumlu uygulamalarda izlemek için AV1, VP9 veya H.265 biçimlerini tercih edin İndirme türü Otomatik Komutlar Biçim tercihi Daha fazla bilgi edin Bilinmeyen Dosyaya aktar Özel yt-dlp, videoları indirmek için güçlü bir komut satırı aracıdır. Seal, sezgisel bir GUI, ortak komutlar için ön ayarlar ve diğer ek özellikler sağlayarak yt-dlp\'nin kullanımını kolaylaştırır. \n \n yt-dlp\'nin gelişmiş kullanımı için Seal, tıpkı bir terminalde olduğu gibi özel komut şablonlarını doğrudan oluşturmanıza, kaydetmenize ve yürütmenize olanak tanır. \n \n Özel komutlar kullanıldığında GUI seçeneklerinin ve özelliklerinin çoğu devre dışı bırakılır. İndirme arşivi temizlensin mi\? Arşiv dosyasındaki %1$s tamamen kaldırılsın mı\? Ön ayarlar Çıktı şablonu Çıktı dosyası adları için şablonu belirtin Tekrarlanan indirmeleri önlemek için indirilen video kimliklerini bir arşive kaydedin Arşivi indir Meta verileri yerleştir Meta verileri ve video küçük resmini ses dosyasına gömün Gerekli %1$d ögenin tümünü göster Kaydet Biçim sıralamasını kullan Uyumluluğu sağlamak için dosya adlarını belirli karakterlerle sınırlandırın Dosya adlarını kısıtla Dosya düzenle İndirdikleriniz şu şekilde kaydedilecek: Alt yazı dosyalarını tut Web sitesi Oynatma listesi başlığı Sistem ayarları IPv4\'ü zorla Tüm bağlantıları IPv4 üzerinden yapın Bir kez izin ver Her zaman izin ver İzin verme Mobil veri üzerinden indirmeye izin verilsin mi? Çoklu ses akışlarını birleştir Çoklu ses akışlarının tek bir dosyada birleştirilmesine izin verin İndirilenlerde ara Ara Otomatik çevrilen alt yazılar Tüm diller için otomatik çevrilen alt yazılar indirmelerde kullanılabilir olacaktır. Bu alt yazılar hatalı ve anlaşılması zor olabilir. Bir sonraki indirme için hatırla Görünüm Önceki seçimi kullan Yok Otomatik biçim seçiminde indirilecek alt yazıların dili, virgülle ayrılmış olarak. Sıfırla Hayır, teşekkürler Alt yazılarda ara Şu diller gelecekteki indirmeler için tercihlerinize eklenecek: Alt yazı dilleri güncellensin mi? İçe aktar Tam yedekleme Yedekleme türü Şuraya aktar Dosya İndirme geçmişi dışa aktarılsın mı? İndirme geçmişi içe aktarılsın mı? İndirme geçmişinden %1$s dışa aktarılıyor. İndirilen dosyalar ve tercihler yedeklenmeyecek. İndirilen dosyalar içe aktarılmayacaktır. Onları elle tekrar indirmeniz gerekecek Dışarı aktar Pano Şuradan içe aktar İndirme geçmişi Arayüz İndirme geçmişine %1$s aktarıldı Yeniden indir Video indirildi. Bu beklenen bir davranış değilse, lütfen indirme arşivinizi gözden geçirin. Yeniden düzenlenen video konteyneri Daha iyi uyumluluk için videoları MKV konteynerine yeniden düzenleyin %1$d çerez (%2$d web sitesinden) Her gün Her hafta Her ay Kullanılabilir en iyi biçimi indir %d video %d video Oynatma listesi Ön ayar %1$s tercih et Biçim tercihlerinizi kullanarak otomatik olarak indirin Ön ayarı düzenle %d ses %d ses Tüm diller Devam et Biçimler, alt yazılar arasından seçim yapın ve daha fazlasını özelleştirin Görev kuyruğa eklendi İndirmeyi başlatmak için indirme düğmesine dokunun veya bu uygulamaya bir video bağlantısı paylaşın İndirmelerinizi burada bulabilirsiniz İndirildi Tümü İndirme kuyruğu %1$d bağlantıdan seç Gezinme çekmecesini göster Devam et Sil Medya bilgileri Yaygın hataları düzeltin ve bilinen sorunları arayın Sorun giderme Bir hatayla mı karşılaştınız? Yeni bir sorun bildirmeden önce lütfen sorun izleyicimizde arama yapın. Birçok yaygın sorun zaten ele alınmış ve orada belgelenmiştir. Sorun izleyici ================================================ FILE: app/src/main/res/values-uk/strings.xml ================================================ Тека для вiдео Зберігати як аудiо Зберігати обкладинку Налаштування Загальні налаштування, формат, власна команда Завантаження Посилання не може бути порожнім Завантажувати та зберігати аудiо, а не вiдео Зберігати обкладинку відео як файл Використовується остання версія yt-dlp Не вдалося встановити останню версію yt-dlp. Переконайтеся, що ви підключені до інтернету. Отримання інформації про відео… Недостатньо повноважень Завантаження завершено Не вдалося завантажити файл Завантаження \"%1$s\" Не вдалося отримати інформацію про відео Загальні Мова застосунку Встановіть мову відображення Завдання на завантаження вже запущено Вставити посилання Не вдалося знайти посилання в буфері обміну Версiя yt-dlp Натисніть, щоб встановити останню версію yt-dlp Вилучити\? Вилучити \"%1$s\" з вашої історії завантажень\? Підтвердити Скасувати Завантаження Аудiо Посилання скопiйовано до буфера обмiну Відкрити посилання Вилучити Видалити файл Про застосунок Версія, зворотний зв\'язок та оновлення Назад Версiя Перегляньте історію змін та нові версії Остання версія Перевірте репозиторій на GitHub та README Вiдео Перевірено Подяки Подяки та вільне програмне забезпечення Власна команда Запустіть yt-dlp зі своїм шаблоном Шаблон команди Редагувати Запустити команду Розширені Детальний вивід Виводити докладні повідомлення під час завантаження Зовнішній вигляд Темна тема, динамічні кольори та мови Темна тема Як у системі Увімкнено Вимкнено Скасувати Налаштуйте перед завантаженням Налаштуйте завантаження перед його початком Параметри завантаження Звіт про помилку скопійовано до буфера обміну Обкладинка Вставити Документація yt-dlp Вихідний шлях та посилання будуть додані застосунком. Конвертувати аудіо формат Не конвертувати Конвертувати у %1$s Формат Перекодування аудіо файлів призведе до погіршення якості звуку та збільшення розміру файлу. Якiсть вiдео Найкраща якiсть Обмежте якість відео за наявності кількох Не вказано (типово) Бажаний формат відео Вибирати цей формат по можливості Формат вiдео Конвертувати Завантажити Закрити Не показувати знову Інструкція Вiдкрити налаштування Натисніть \"Вставити\", щоб отримати посилання на відео з буфера обміну. Після чого натисніть \"Завантажити\", щоб почати завантаження. Перевіряйте та керуйте завантаженнями в застосунку, включаючи відео та аудіо. Спочатку ознайомтеся з налаштуваннями завантажень та переконайтеся, що у вас встановлена остання версія yt-dlp. Завантажувати плейлисти Завантажувати декілька відео з плейлиста Типово Завантаження Повідомляти про прогрес та закінчення завантажень Посилання на вiдео Завантаження завершено. Натисніть, щоб відкрити. Виконання користувацьких команд… Встановіть у налаштуваннях системи режим використання батареї \"Необмежений\" для цього застосунка, щоб завантажувати ваші відео та аудіо у фоновому режимі. Багатопотокове завантаження Завантажувати більше частин M3U8/MPD відео паралельно %d потоків буде використано для завантаження DASH/HLS вiдео. Опції Додаткові налаштування Неможливо знайти посилання Читаю посилання… Більше дій Повідомлення про завантаження "Повідомляти про завантаження" Збираємо інформацію про плейлист… Вибір плейлиста Позначити початок та кінець списку завантажень із плейлиста \"%3$s\" (з %1$d по %2$d). Початок Кінець Неіснуючий діапазон індексу Завантаження плейлиста (%1$d/%2$d)… Тека для аудiо Тека для завантажень Оберіть, де зберігати файли Зберігати до підтек Зберігайте файли в теках з відповідними назвами Непідтримувана директорія Директорії за межами Documents/ й Download/ не пiдтримуються Налаштування батареї Ігнорувати оптимізації батареї для фонових завантажень Seal завантажує… Невідома помилка Перекласти Допоможіть перекласти Seal на Weblate Шаблон шляху Вбудовані субтитри Вставляти м\'які субтитри у відео, якщо вони доступні Новий шаблон Назва Видалити\? Назавжди видалити \"%1$s\" з шаблонів команд\? Вибрати шаблони Керувати шаблонами команд Завантаження в процесі… Завантаження скасовано Повідомити про проблему (GitHub) Надіслати звіт про помилку або запропонувати нову функцію Інформація скопійована в буфер обміну Відкрити файл Поставлено в чергу Завершено Завантаження Cкасовано Отримання інформації Перезавантажити Помилка Скопіювати посилання Скопіювати помилку Розмір відео Роздільна здатність Видалити усі тимчасові файли з тимчасової теки Видалено %1$d тимчасовий(і/их) файл(и/ів) Вкажіть категорії SponsorBlock, що будуть видалені або позначені у відеофайлі Перевірити наявність оновлень Поточна версія актуальна Оновлення Використовувати файли cookies у Netscape форматі для завантажень Автоматично перевіряти GitHub на нові версії Використовувати aria2c в якості зовнішнього завантажувача Тимчасові файли можна використовувати для відновлення скасованих завантажень. Ви впевнені, що хочете видалити всі ці файли\? \n \nВи можете отримати доступ до цих файлів у %1$s Не вдалося оновити до останньої версії Видалити тимчасові файли Експортувати в буфер обміну Імпортувати з буфера обміну Експортовано %1$d шаблон(и/ів) Імпортовано %1$d шаблон(и/ів) %1$d Завдань для завантаження Нещодавно додані %1$d відеофайл(и/ів), %2$d аудіофайл(и/ів) Видалити %1$d елемент(и/ів) з історії завантажень назавжди\? Видаляти або позначати сегменти у відео за допомогою SponsorBlock API Категорії SponsorBlock Темна тема з високим контрастом Неправильні дані Найгірша якість Режим множинного вибору Інкогніто Вимкнути історію завантажень Динамічний колір Застосовувати кольори зі шпалер до теми застосунку Файл більше недоступний Мережа Ліміт швидкості Максимальна швидкість Дозволяти завантаження медіа під час підключення до мобільних мереж Завантаження через мобільну мережу вимкнено відповідно до ваших налаштувань Завантажувати через мобільну мережу Обмежте максимальну швидкість завантаження %1$d вибрано Yt-dlp версія, сповіщення, список відтворення Обмеження швидкості, завантажувач та cookies Не відображати обкладинки під час завантаження Конфіденційність Використовувати ці команди Приватна тека Зберігати завантаження у прихованій теці Обрізати зображення Обрізати вбудоване зображення у квадрат Вибрати все Вибір формату Виберіть відео для завантаження зі списку відтворення \"%1$s\" Згенерувати нові cookies Запропоноване Відео (без аудіо) Виберіть формат перед початком завантаження Недоступно Формат файлу, якість відео та субтитри Вимкнути попередній перегляд Telegram канал Як це працює\? Використовувати cookies Видалити цей запис для \"%1$s\"? Зверніть увагу, що файли cookies, збережені для цього сайту, не будуть видалені. Деякі параметри недоступні при використанні користувацької команди Завантаження з деяких сайтів вимагає інформації про автентифікацію облікового запису. Натисніть «Згенерувати нові cookies», введіть URL-адресу вебсайту, а потім увійдіть за допомогою свого облікового запису на сторінці браузера, застосунок згенерує його для вас. Кімната Matrix Cookies Більшість платформ надають аудіо та відео окремо, але ви можете об\'єднати формати \"лише відео\" та \"лише аудіо\" у єдине відео. Приклад автора відео Мови субтитрів Показати журнал Автоматичні субтитри Тека SD-карти Завантажувати автоматично створені субтитри Копіювати лог Очистити Швидке завантаження Приклад назви відео Субтитри Завантажувати субтитри Мови, вбудовані субтитри, автоматичні субтитри Додати Запущені завдання Редагувати комбінації клавіш Комбінації клавіш Відредагуйте власні комбінації клавіш, які можна використовувати для створення шаблонів команд. Лог При видаленні сегментів SponsorBlock субтитри можуть з\'являтися не вчасно. Для вбудовування м\'яких субтитрів, відео буде перетворено у контейнер mkv. Ви зможете використати VLC Media Player або інші сумісні застосунки для перегляду. %.2f ГБ %.2f МБ Стабільний Предперегляд Поділитися Канал оновлень Оновлювати автоматично Встановіть версію предперегляд, щоб переглянути нові функції та зміни. \n \nУ цих версіях буде спостерігатися деяка нестабільність, тому, будь ласка, не соромтеся повідомляти нам про будь-які проблеми, щоб допомогти нам покращити застосунок у майбутньому. Увімкнути оновлення Застосувати Відеокліп Початок Кінець Відхилити Бажаний формат аудіо Найнижчий бітрейт Якість аудіо Обмежувати бітрейт аудіо при наявності кількох якостей Сортування форматів Сортування форматів за допомогою опції -S для yt-dlp Імпортувати Назва Перейменувати секунди хвилини Очистити всі файли cookie Без обмежень Назавжди видалити всі файли cookie, які зберігаються у застосунку\? Зберігати тимчасові файли у внутрішній теці Спонсорувати Зворотний зв\'язок Спонсори Підтримайте цей застосунок, спонсоруючи нас на GitHub Seal завжди буде безплатним і відкритим для всіх. Якщо вам це подобається, подумайте про те, щоб стати моїм спонсором на GitHub! Немає завантажених медіа Бета Створюйте відеокліпи на сторінці вибору формату Аудіо формат Увімкнути експериментальну функцію\? Завантаження за допомогою цієї функції буде делеговано FFmpeg для завантаження вибраних фрагментів відео, ця функція все ще експериментальна і розрізання не буде повністю точним, не всі формати підтримують цю функцію, і ви можете відчути повільну швидкість завантаження. Автоматичне оновлення недоступне для %1$s збірок. Якщо на вашому пристрої не встановлено %1$s або ви хочете переглянути майбутні нові функції в Seal, розгляньте %2$s. перехід на збірки GitHub Ок Зрозумів Функція недоступна Немає спеціальних командних завдань Допис від розробника Велике спасибі! Завантажити відео з URL-адреси Конвертувати субтитри Конвертуйте субтитри в інший формат Розділити відео Скопіювати та вийти Відео буде розділене на %1$d частини Ой! Щось пішло не так Розгорнути Нове завдання на завантаження Почати Редагувати \"%1$s\" Проксі Застарілий Якість Увімкнути сповіщення\? Вимкнути Тека для власних команд Вибирати MP4 (H.264) для обміну у інших застосунках Вибирати AV1, VP9 або H.265 для перегляду у сумісних застосунках Використовувати проксі для підключень до інтернету Оновити yt-dlp Додатку потрібен ваш дозвіл, щоб надсилати сповіщення про стан завантаження. Натисніть, щоб налаштувати теку Вимкнено Вибір теки Вкажіть вихідну теку для власних команд Тип завантаження Власний Налаштування формату Невідомо Авто Дізнатися більше Команди Натисніть, щоб відкрити вебсторінку для створення нових файлів cookie: Видалити %1$s з шаблонів команд назавжди\? %d елемент %d елементи %d елементів %d елементів Заголовок User-Agent Експорт у файл yt-dlp – це потужний інструмент командного рядка для завантаження відео. Seal полегшує використання yt-dlp, надаючи інтуїтивно зрозумілий графічний інтерфейс, пресети для поширених команд та інші додаткові можливості. \n \nДля розширеного використання yt-dlp, Seal дозволяє створювати, зберігати й виконувати власні шаблони команд безпосередньо, як у терміналі. \n \nПід час використання власних команд більшість опцій та можливостей графічного інтерфейсу буде вимкнено. Пресети Шаблон виводу Вкажіть шаблон для назв вихідних файлів Очистити архів завантажень\? Видалити %1$s з файлу архіву назавжди\? Записувати ID завантажених відео в архів, щоб уникнути дублікатів Архів завантажень Вставити метадані Обов\'язковий Вставляти метадані та обкладинку відео до аудіофайлу Показати всі елементи (%1$d) Зберегти Використовуйте сортування за форматом Обмежте назви файлів певними символами, щоб забезпечити сумісність Обмежити імена файлів Редагувати файл Ваші завантаження будуть збережені як: Вебсайт Назва списку відтворення Налаштування системи Примусово використовувати IPv4 Здійснювати всі підключення через IPv4 Зберігати файли субтитрів Дозволити один раз Завжди дозволяти Не дозволяти Дозволити завантаження через мобільні дані? Об\'єднувати кілька аудіопотоків Дозволяє об\'єднувати кілька аудіопотоків в один файл Пошук у завантаженнях Пошук У завантаженнях можна буде вибрати субтитри, створені автоматично для кожної мови. Вони можуть бути неточними та важкими для розуміння. Субтитри з автоперекладом Запам\'ятати для майбутнього завантаження Мова субтитрів для завантаження в автоматичному виборі формату, розділених комами. Тип резервного копіювання Експортувати Імпортувати Експортувати до Завантажені файли не будуть імпортовані. Вам потрібно завантажити їх вручну Використовувати попередньо вибране Нічого Скинути Пошук в субтитрах Ні, дякую Наступні мови будуть додані до ваших уподобань для майбутніх завантажень: Оновити мови субтитрів? Файл Буфер обміну Імпортувати з Експортувати історію завантажень? Імпортувати історію завантажень? Історія завантажень Імпортований %1$s до історії завантажень Завантажити повторно Повне резервне копіювання Експортування %1$s з історії завантажень. Завантажені файли і налаштування не будуть повернені. Відео було завантажено. Якщо це не очікувана поведінка, будь ласка, перевірте ваш архів завантаження. Контейнер для відео Remux Щотижня Щомісяця Remux відео в контейнер MKV для кращої сумісності Всього %1$d файлів cookie з %2$d вебсайтів Щодня Всі мови Список відтворення Надаю перевагу %1$s %d відео %d відео %d відео %d відео %d аудіо %d аудіо %d аудіо %d аудіо Продовжити Передустановка Вибирайте формати, субтитри та налаштовуйте далі Завантажуйте автоматично відповідно до ваших налаштувань формату Редагувати передустановку Завантажте найкращий доступний формат Задачу додано до черги Ви знайдете свої завантаження тут Натисніть на кнопку завантаження або поділіться відео посиланням на цей застосунок, щоби почати завантаження Завантажено Все Завантаження у черзі Виберіть з %1$d посилань Показати панель навігації Відновити Видалити Інформація про медіа Виникла помилка? Перш ніж повідомляти про нову проблему, будь ласка, скористайтеся нашим відстеженням проблем. Багато поширених проблем вже вирішено і задокументовано там. Усунення поширених помилок і перевірка на наявність відомих помилок Усунення несправностей Відстежування проблем Збережені посилання Додати нове посилання Додати до %1$s ================================================ FILE: app/src/main/res/values-ur/strings.xml ================================================ ویڈیو فولڈر ڈاؤن لوڈ مکمل تازہ ترین yt-dlp ورژن انسٹال نہیں ہو سکا۔ براہ کرم یقینی بنائیں کہ آپ انٹرنیٹ سے جڑے ہوئے ہیں۔ اجازت نامنظور فائل ڈاؤن لوڈ نہیں ہو سکی آڈیو میں محفوظ کریں تھمبنیل محفوظ کریں سیٹنگ جرنل، فارمیٹ، کسٹم کمانڈ ڈاؤنلوڈ لنک خالی نہیں ہو سکتا ویڈیو کے بجائے آڈیو ڈاؤن لوڈ اور محفوظ کریں ویڈیو تھمب نیل کو بطور فائل محفوظ کریں yt-dlp کا تازہ ترین ورژن استعمال کرنا ویڈیو کی معلومات حاصل کی جا رہی ہے… ================================================ FILE: app/src/main/res/values-uz/strings.xml ================================================ Video eskizini fayl sifatida saqlash Video maʼlumotlarini yuklab boʻlmadi Versiya, nashrlar, avtomatik yangilash Buyruq shabloni Displey tili Displey tilini o\'rnatish Mavjud yuklash vazifasi allaqachon ishlamoqda Buferdan URL manzilni joylashtirish Buferdagi URL manziliga mos kelmadi Yt-dlp versiyasi Eng so\'nggi yt-dlp versiyasini o\'rnatish uchun bosing O\'chirilsinmi\? “%1$s” yuklangallar tarixidan butunlay olib o\'chirilsinmi\? Tasdiqlash Bekor qilish Yuklanganlar Audio Havola nusxalandi Havolani ochish O\'chirish Oʻchirish Ortga Versiya O\'zgartirishlarni va yangi versiyalarni qidirish Oxirgi nashr GitHub va README-ni tekshirish Video Tekshirildi Ma\'lumotlar Ma\'lumotlar va bepul dasturlar Maxsus buyruq Maxsus shablon bilan yt-dlp buyrug\'ini ishga tushirish Audio sifatida saqlash Eskizni saqlash Sozlamalar Umumiy, format, maxsus buyruq Yuklash Video o\'rniga audioni yuklab olish va saqlash yt-dlpning so\'nggi versiyasidan foydalanish yt-dlpning so\'ngi versiyasini o\'rnatib bo\'lmadi. Internetga ulanganingizga ishonch hosil qiling. Video haqida maʼlumot yuklanmoqda… Ruxsat berilmagan Yuklash tugallandi Faylni yuklab bo‘lmadi “%1$s” ni yuklash Video uchun papka Umumiy Havola bo\'sh bo\'lishi mumkin emas Ilova haqida Audio formatga aylantirish Audio fayllarni qayta kodlash audio sifatining yo\'qolishiga va fayl hajmining oshishiga olib keladi. Bir nechta taqdim etilganda afzal format Aylantirmaslik %1$s ga aylantirish Video sifati Eng yashi sifat Tanlangan video formati Video formati Aylantirish Yuklash Yopish Boshqa ko\'rsatmaslik Foydalanuvchi qo\'llanma Sozlamalarni ochish Pleylistni yuklash Foydalanuvchi buyruqlar ishga tushirilmoqda… Iltimos, fonda yuklab olish uchun ushbu ilovaning batareya quvvatini tizim sozlamalarida “Cheklanmagan” qilib belgilang. Ko\'p tarmoqli yuklab olish %d ta ip bir vaqtda DASH/HLS mahalliy videosini yuklab olish uchun ishlatiladi. Sozlamalar Qo\'shimcha sozlamalar Yuklab olingan fayllar va muvaffaqiyat haqida xabar berish Pleylist haqida ma\'lumot olish… Pleylistni tanlash “%3$s” pleylistidan yuklab olinadigan videolar oralig‘ini belgilang (%1$d dan %2$d gacha). Boshlash Tugallash Indeks oralig‘i noto‘g‘ri Audio papkasi yt-dlp ni yangilash Bekor qilish Format Bir nechta video mavjud bo\'lsa, video sifatini cheklash Belgilanmagan (standart) Umumiy kontentdan URL manziliga mos kelmadi Pleylistni yuklash (%1$d/%2$d)… Avto yangilash Buyruq shablonlarini yaratish uchun ishlatilishi mumkin bo\'lgan maxsus yorliqlarni tahrirlang. Yuklash yakunlandi. Ochish uchun bosing. SponsorBlock segmentlarini olib tashlashda subtitrlar noto\'g\'ri bo\'lishi mumkin. Taglavhalarni joylashtirish uchun videolar mkv konteyneriga qayta ishlanadi. O\'rnatilgan subtitrlar bilan videolarni tomosha qilish uchun VLC Media Player yoki boshqa mos ilovalardan foydalanishingiz mumkin. %.2f MB %.2f GB Yangi funksiyalar va oʻzgarishlarni koʻrish uchun relizdan oldingi tuzilmalarni oʻrnating. \n \nUshbu versiyalarda biroz beqarorlik boʻladi, shuning uchun kelajakda ilovani yaxshilashga yordam beradigan muammolarga duch kelsangiz, bizga fikr bildirishdan tortinmang. Buyruqni bajarish Ilg\'or sozlamalar Batafsil hisobot Yuklash vaqtida batafsil hisobotni chiqarish Tizim Yoqilgan Yuklash oldingi sozlamalar Tashqi ko\'rinish Tungi mavzu Yuklab olishdan oldin sozlamalarni sozlang Ushbu yuklashni sozlang Xatolik hisoboti vaqtinchalik xotiraga nusxalandi Eskiz Kiritmoq Yt-dlp foydalanish havolalari Chiqish yoʻli va URL ilova tomonidan qoʻshiladi. Keyin sozlamalarni o\'rnatganingizdan so\'ng \"Yuklab olish\" tugmasini bosing. Ilova ichidagi yuklamalarni, jumladan, video va audio fayllarni tekshiring va boshqaring. Yuklab olish sozlamalari bilan tanishib chiqing va uni ishlatishdan oldin yt-dlp ning eng so\'nggi versiyasiga ega ekanligingizga ishonch hosil qiling. Standard Yuklash Yuklab olingan fayllar va muvaffaqiyat haqida xabar bering Umumiy kontentdan video havolasi o‘qilmoqda… Koʻproq amallarni koʻrsatish Yuklanma bildirishnomasi Qo\'shish Yorliqlar Barqaror O\'chirilgan Tungi mavzu, yorqin rang, tillar Buferdan video havolasini olish uchun \"Joylashtirish\" tugmasini bosing. Pleylistdan bir nechta videolarni yuklab oling M3U8/MPD videolarining boshqa qismlarini parallel ravishda yuklab oling Jurnal Oldindan ko\'rish Yangilanish kanali Tahrirlash Ishga tushirilgan vazifalar Jurnalni ko\'rsatish Ulashish Video havolasi ================================================ FILE: app/src/main/res/values-vi/strings.xml ================================================ Thư mục video Lưu dưới dạng âm thanh Lưu hình thu nhỏ Thiết đặt Tổng quan, định dạng, lệnh tùy chỉnh Tải xuống Đường dẫn không được bỏ trống Tải xuống và lưu âm thanh, thay vì video Lưu hình thu nhỏ dưới dạng tệp Đang sử dụng phiên bản mới nhất của yt-dlp Không thể cài đặt phiên bản mới nhất của yt-dlp. Hãy chắc chắn bạn đã kết nối internet. Đang nạp thông tin video… Quyền bị từ chối Đã hoàn thành tải xuống Không thể tải xuống tệp Tải xuống \"%1$s\" Không thể nạp thông tin video Tổng quan Ngôn ngữ hiển thị Đặt ngôn ngữ hiển thị Có sẵn một tác vụ tải xuống đang chạy Dán URL từ bàn phím Không thể khớp với URL trong khay nhớ tạm Phiên bản yt-dlp Nhấn để cài đặt phiên bản yt-dlp mới nhất Gỡ bỏ\? Gỡ \"%1$s\" khỏi lịch sử tải xuống của bạn\? Xác nhận Hủy bỏ Tải xuống Âm thanh Đã sao chép link tới khay nhớ tạm Mở link Gỡ bỏ Xóa tệp Thông tin Phiên bản, phản hồi, cập nhật tự động Quay lại Phiên bản Các thay đổi và phiên bản mới Phiên bản mới nhất Video Đã kiểm tra Danh đề Các đóng góp và phần mềm libre Lệnh tùy chỉnh Chạy lệnh yt-dlp với mẫu tùy chỉnh Mẫu lệnh Đầu ra chi tiết In tin nhắn chi tiết khi đang tải xuống Hiển thị Chủ đề tối, màu sắc sống động, ngôn ngữ Chế độ tối Hệ thống Bật Tắt Hủy bỏ Cấu hình trước khi tải xuống Định thiết lập tùy chọn trước khi tải xuống Điều chỉnh tải xuống Báo cáo lỗi đã được sao chép vào khay nhớ tạm Hình thu nhỏ Dán Tham khảo sử dụng yt-dlp Đường dẫn đầu ra và URL sẽ được thêm bởi ứng dụng. Chuyển đổi định dạng âm thanh Không đổi Chuyển đổi sang %1$s Định dạng Mã hóa lại các tệp âm thanh sẽ làm giảm chất lượng âm thanh và tăng kích thước tệp. Chất lượng video Chất lượng tốt nhất Định dạng video ưa thích Định dạng ưa thích khi nhiều định dạng được cung cấp Tải xuống Đóng Không hiển thị lại Hướng dẫn người dùng Mở thiết đặt Nhấp vào \"Dán\" để lấy link video từ khay nhớ tạm. Kế đến nhấp vào \"Tải xuống\" sau khi điều chỉnh thiết đặt của nó. Kiểm tra và quản lý Tải xuống trong ứng dụng, gồm tệp video và âm thanh. Hãy xem thiết đặt tải xuống và đảm bảo bạn có phiên bản yt-dlp mới nhất trước khi sử dụng. Tải xuống danh sách phát Tải xuống nhiều video từ một danh sách phát Mặc định Tải xuống Thông báo về các tệp đã tải xuống và tiến trình Đường dẫn video Đã hoàn thành tải xuống. Nhấn để mở. Tải xuống đa luồng Tải xuống song song nhiều phần của video M3U8/MPD %d chuỗi sẽ được sử dụng cùng lúc để tải xuống video gốc DASH/HLS. Tùy chọn Không thể khớp URL từ nội dung được chia sẻ Đang đọc dường dẫn video từ nội dung được chia sẻ… Hiển thị thêm các hành động Thông báo tải xuống Thông báo các tệp đã tải xuống và tiến trình Bắt đầu Xem qua Github repo và README Chỉnh sửa Bắt đầu thực thi lệnh Nâng cao Chuyển đổi Giới hạn chất lượng khi có nhiều video Không xác định (mặc định) Định dạng video Thiết đặt bổ sung Phạm vi chỉ mục không hợp lệ Đang chạy lệnh tùy chỉnh… Đang nạp thông tin danh sách phát… Chỉ định phạm vi video sẽ tải xuống từ danh sách phát \"%3$s\" (từ %1$d đến %2$d). Hãy chuyển mức sử dụng pin của ứng dụng này sang \"Không giới hạn\" trong thiết đặt hệ thống để tải xuống trong nền. Lựa chọn danh sách phát Kết thúc Đang tải xuống danh sách phát (%1$d/%2$d)… Thư mục âm thanh Kiểm tra cập nhật Tự động kiểm tra phiên bản mới nhất trên Github Phiên bản hiện tại là mới nhất Cập nhật phiên bản mới nhất thất bại Cập nhật Sử dụng aria2c làm trình tải xuống bên ngoài Sử dụng cookie được định dạng Netscape để tải xuống Dọn các tệp tạm thời Đã xóa %1$d tập tin tạm thời Các tệp tạm có thể được dùng để khôi phục các tải xuống bị hủy. Bạn có chắc xóa tất cả các tệp này\? \n \nBạn có thể truy cập các tệp này trong %1$s Thư mục tải xuống Chọn nơi lưu trữ tệp video và âm thanh Lưu vào thư mục con Lưu tệp vào các thư mục được đặt tên theo trường tương ứng Vấn đề quyền truy cập bộ nhớ Các đường dẫn ngoài Download/ và Documents/ không được hỗ trợ Cấu hình pin Bỏ qua Tối ưu hóa pin để ứng dụng này tải xuống trong nền Seal đang tải xuống… Lỗi không xác định Dịch Giúp dịch ứng dụng này trên Hosted Weblate Mẫu đường dẫn Nhúng phụ đề Nhúng phụ đề rời vào video nếu có Bản mẫu mới Nhãn Loại bỏ\? Loại bỏ \"%1$s\" khỏi bản mẫu\? Lựa chọn bản mẫu Chỉnh sửa và quản lý lệnh mẫu Đang tải xuống… Đã hủy tác vụ tải xuống Vấn đề Github Nộp một báo cáo lỗi hoặc yêu cầu tính năng Thông tin đã được sao chép vào khay nhớ tạm Mở tệp Khởi động lại Đã cho vào hàng Đã hoàn thành Đang tải Đã hủy Đang nạp thông tin Lỗ Sao chép link Sao chép báo cáo Độ phân giải video Kích thước tệp video Xóa tất cả các tệp tạm thời khỏi thư mục tạm thời Xuất sang khay nhớ tạm Nhập từ khay nhớ tạm Đã xuất %1$d mẫu Đã nhập %1$d mẫu %1$d tiến trình tải xuống Thêm vào gần đây %1$d video, %2$d tệp âm thanh Loại bỏ %1$d mục khỏi lịch sử tải xuống\? Loại bỏ hoặc đánh dấu các phân đoạn có trong video bằng SponsorBlock API Chỉ định danh mục SponsorBlock sẽ bị xóa hoặc đánh dấu trong tập tin video Danh mục SponsorBlock Chế độ đa lựa chọn Chọn tất cả Tắt lịch sử tải xuống Tải xuống bằng di động Ẩn danh Tải xuống bằng mạng di động bị tắt theo thiết đặt của bạn Tập tin này không còn khả dụng nữa Giới hạn tốc độ tải xuống tối đa Màu động Áp dụng màu từ hình nền cho chủ đề ứng dụng Cho phép tải xuống phương tiện khi được kết nối với mạng có đồng hồ đo Mạng Tốc độ giới hạn Tốc độ tối đa Bắt đầu Kết thúc Oops! Có cái gì đó không đúng Định dạng âm thanh ưa thích Chất lượng âm thanh Tốc độ bit thấp nhất Không giới hạn Sắp xếp định dạng với tùy chọn -S của yt-dlp Sắp xếp định dạng giây phút Lựa chọn video để tải xuống từ danh sách phát \"%1$s\" Không có phương tiện đã tải xuống Beta Bật tính năng thử nghiệm\? %.2f MB Chủ đề tối tương phản cao Cắt vuông các hình ảnh nhúng Video (không âm thanh) Tắt xem trước Tốc độ giới hạn, trình tải xuống, cookie Không hiển thị hình thu nhỏ trong khi tải xuống Chia sẻ Kênh cập nhật Bật tự động cập nhật Tự động cập nhật Khu vực Matrix Văn bản mẫu tiêu đề video Đa số các nền tảng phát trực tuyến video đều phân phối âm thanh và video riêng biệt, bạn có thể chọn và hợp nhất định dạng chỉ có âm thanh với định dạng chỉ có video thành một video duy nhất. Seal sẽ luôn miễn phí và là mã nguồn mở cho tất cả mọi người. Nếu bạn thích nó, vui lòng xem xét tài trợ cho tôi trên GitHub! Tiêu đề Xóa mục này cho \"%1$s\"? Xin lưu ý rằng các cookie được lưu trữ cho trang web này sẽ không bị xóa. Các lượt tải xuống sử dụng tính năng này sẽ được ủy quyền cho FFmpeg để tải xuống các phần đã chọn của video, tính năng này vẫn đang thử nghiệm và việc cắt sẽ không hoàn toàn chính xác, không phải tất cả các định dạng đều hỗ trợ tính năng này và bạn có thể gặp phải tốc độ tải xuống chậm hơn. Cắt video Đầu vào không hợp lệ Chất lượng thấp nhất Không khả dụng Định dạng tập tin, chất lượng video, phụ đề Phiên bản Yt-dlp, thông báo, danh sách phát Riêng tư Thư mục riêng Một vài tùy chọn không khả dụng khi sử dụng lệnh tùy chỉnh Tải xuống từ một số trang yêu cầu thông tin xác thực tài khoản. Nhấp vào \"Tạo cookie mới\", nhập URL của trang web rồi đăng nhập bằng tài khoản của bạn trên trang trình duyệt, ứng dụng sẽ tạo nó cho bạn. Ngôn ngữ, phụ đề nhúng, phụ đề tự động Sao chép nhật ký Xóa Áp dụng Để nhúng phụ đề rời, video sẽ được làm lại vào vùng chứa mkv. Bạn có thể sử dụng VLC Media Player hoặc các ứng dụng tương thích khác để xem video có phụ đề rời. Xem trước Tùy chỉnh lối tắt Cài đặt bản dụng trước khi phát hành để xem trước các tính năng và thay đổi \n \nSẽ có một số sự không ổn định trong các phiên bản này, vì vậy đừng ngại gửi phản hồi cho chúng tôi nếu bạn gặp bất kỳ sự cố nào để giúp chúng tôi cải thiện ứng dụng trong tương lai. Phụ đề có thể bị lệch thời gian khi loại bỏ phân đoạn SponsorBlock. Giới hạn tốc độ bit âm thanh khi có nhiều chất lượng Xóa sạch cookie Xóa sạch hoàn toàn tất cả các cookie được lưu trữ trong ứng dụng\? Lưu trữ các tập tin tạm thời trong thư mục nội bộ Tài trợ Hỗ trợ ứng dụng này bằng cách tài trợ trên GitHub chuyển sang các bản dựng GitHub Tự động cập nhật không khả dụng cho các bản dựng %1$s. Nếu bạn chưa cài đặt %1$s trên thiết bị của mình hoặc muốn xem trước các tính năng mới sắp tới trong Seal, vui lòng xem xét %2$s. Đồng ý Tính năng không khả dụng Tin nhắn từ nhà phát triển Đã hiểu Cám ơn bạn rất nhiều! Chuyển đổi phụ đề sang một định dạng khác Chuyển đổi phụ đề Video sẽ được tách thành %1$d chương Sao chép và thoát Cookies Cắt hình ảnh Lựa chọn định dạng Tạo cookie mới Nó hoạt động như thế nào\? Kênh Telegram Nhiệm vụ đang chạy Hiển thị nhật ký Nhật ký %.2f GB Lối tắt Chỉnh sửa các lối tắt tùy chỉnh có thể được sử dụng để soạn các mẫu lệnh. Ổn định Hủy bỏ Nhập Đổi tên Tạo video clip trong trang chọn định dạng Không có nhiệm vụ lệnh tùy chỉnh Tải video xuống từ URL Tách video Mở rộng Nhiệm vụ tải xuống mới Bắt đầu Chỉnh sửa \"%1$s\" Lưu trữ các tải xuống trong một thư mục ẩn Lựa chọn định dạng để tải xuống trước khi bắt đầu tải xuống Sử dụng lệnh tùy chỉnh Sử dụng cookie %1$d đã chọn Đề xuất Thư mục thẻ SD Phụ đề tự động Tải xuống phụ đề được tạo tự động Tải xuống nhanh Văn bản mẫu người sáng tạo video Phụ đề Tải xuống phụ đề Ngôn ngữ phụ đề Thêm Phản hồi Nhà tài trợ Định dạng âm thanh Cập nhật yt-dlp Proxy Nhấn để thiết lập đường dẫn thư mục Ưu tiên định dạng AV1, VP9 hoặc H.265 để xem trong các ứng dụng tương thích Sử dụng proxy cho các kết nối Internet Truyền thống Chất lượng Bật thông báo\? Ứng dụng cần được cấp quyền để bật thông báo về trạng thái tải xuống và quá trình. Tắt Đường dẫn thư mục lệnh tùy chỉnh Đã tắt Chỉ định thư mục đầu ra khi sử dụng lệnh tùy chỉnh Trình chọn thư mục Ưu tiên định dạng MP4(H.264) để chia sẻ với các ứng dụng khác yt-dlp là công cụ dòng lệnh mạnh mẽ để tải xuống băng hình. Seal giúp sử dụng yt-dlp dễ dàng hơn bằng cách cung cấp GUI trực quan, cài đặt trước cho các lệnh phổ biến và các tính năng bổ sung khác. \n \nĐể sử dụng yt-dlp nâng cao, Seal cho phép bạn tạo, lưu và thực thi trực tiếp các mẫu lệnh tùy chỉnh, giống như trong một thiết bị đầu cuối. \n \nKhi sử dụng các lệnh tùy chỉnh, hầu hết các tùy chọn và tính năng GUI sẽ bị tắt. Loại tải xuống Xóa kho lưu trữ tải xuống\? Xuất thành tệp Lệnh Loại bỏ vĩnh viễn %1$s trong tệp lưu trữ\? Không xác định Tìm hiểu thêm Các cài đặt trước Đầu đề tác nhân người dùng Tùy chọn định dạng Mẫu đầu ra Chỉ định mẫu cho tên tệp đầu ra Nhấn để mở trang web tạo cookie mới: Ghi lại ID video đã tải xuống vào kho lưu trữ để tránh tải xuống trùng lặp Tải xuống kho lưu trữ Loại %1$s khỏi mẫu lệnh vĩnh viễn\? Tự động %d mục Tùy chỉnh Nhúng siêu dữ liệu Nhúng siêu dữ liệu và hình thu nhỏ video vào tệp âm thanh Yêu cầu Hiển thị tất cả %1$d mục Lưu Sử dụng sắp xếp định dạng Giới hạn tên tệp ở các ký tự cụ thể để đảm bảo tính tương thích Hạn chế tên tệp Chỉnh sửa tệp Bản tải xuống của bạn sẽ được lưu dưới dạng: Giữ các tệp phụ đề Trang mạng Tiêu đề danh sách phát Thiết đặt hệ thống Buộc IPv4 Thực hiện tất cả các kết nối qua IPv4 Cho phép một lần Luôn cho phép Cho phép tải xuống bằng mạng di động? Hợp nhất nhiều luồng âm thanh Cho phép hợp nhất nhiều luồng âm thanh vào một tệp duy nhất Không cho phép Phụ đề dịch tự động cho tất cả các ngôn ngữ sẽ có sẵn trong phần tải xuống. Những phụ đề này có thể không chính xác và khó hiểu. Phụ đề được dịch tự động Tìm kiếm trong phần tải xuống Tìm kiếm Ngôn ngữ của phụ đề tải về ở định dạng Auto chọn lọc, phân cách bằng dấu phẩy. Ghi nhớ cho lần tải tiếp theo Cái nhìn & cảm nhận Sử dụng lựa chọn trước đó Không có Đặt lại Tìm kiếm trong phụ đề Không, cám ơn Các ngôn ngữ sau sẽ được thêm vào tùy chọn của bạn để tải xuống trong tương lai: Cập nhật ngôn ngữ phụ đề? Xuất lịch sử tải xuống? Đang xuất %1$s từ lịch sử tải xuống. Các tệp và tùy chọn đã tải xuống sẽ không được sao lưu. Giao diện và tương tác Xuất Nhập Sao lưu đầy đủ Kiểu sao lưu Xuất sang Tệp Bảng nhớ tạm Nhập từ Nhập lịch sử tải xuống? Các tệp đã tải xuống sẽ không được nhập. Bạn sẽ cần phải tải chúng xuống theo cách thủ công Lịch sử tải xuống Đã nhập %1$s vào lịch sử tải xuống Tải xuống lại Video đã được tải xuống. Nếu đây không phải là hành vi mong đợi, vui lòng kiểm tra kho lưu trữ tải xuống của bạn. Thùng chứa video remux Remux video vào vùng chứa MKV để có khả năng tương thích tốt hơn Tổng cộng %1$d cookie từ %2$d trang web Hằng ngày Hàng tuần Hàng tháng %d video Tất cả ngôn ngữ Danh sách phát Tiếp tục ­Ưu tiên %1$s Tự động tải xuống sử dụng các thiết lập định dạng của bạn Cài đặt trước Chỉnh sửa cài đặt trước Tải xuống định dạng có sẵn tốt nhất Nhiệm vụ đã thêm vào danh sách chờ %d âm thanh Chọn từ các định dạng, phụ đề và tùy chỉnh nhiều hơn ================================================ FILE: app/src/main/res/values-zh-rCN/strings.xml ================================================ 视频链接 视频目录 保存为音频 保存视频封面 设置 常规设置、下载格式、自定义命令 下载并保存音频,而非视频 以文件形式保存视频封面 yt-dlp 已更新到最新版本 更新 yt-dlp 失败,请检查与GitHub的连接 正在获取视频信息… 未取得相关权限 下载视频时发生错误 开始下载 %1$s 获取视频信息时发生错误 下载完成 常规 界面语言 设置界面语言 已有正在运行中的下载任务 粘贴 URL 未在剪贴板中匹配到视频链接 下载 视频链接不能为空 下载组件版本 点此更新yt-dlp 更新 yt-dlp 从下载记录移除 从下载记录中永久移除 “%1$s” 吗? 确认 取消 下载记录 音频 链接已复制到剪贴板 打开链接 移除 删除文件 关于 版本、意见反馈、自动更新 返回 当前版本 版本发布 查看最新版本与更新日记 查看 GitHub 项目地址与应用说明 视频 已选中 致谢 资源与开源软件 自定义命令 使用自定义的命令模板运行 yt-dlp 命令模板 编辑 开始执行命令 高级 显示详细信息 显示详细的界面信息与错误报告 显示 深色模式 跟随系统 开启 关闭 取消 下载前设置 在开始下载前确认下载设置 错误报告已复制到剪贴板 缩略图 粘贴 yt-dlp 使用文档 应用会自动向命令中添加输出路径与链接 音频格式转换 不进行格式转换 转换为 %1$s 格式 格式 重编码音频文件将会造成音质损失并增大文件大小。 视频画质 最高画质 下载画质不高于所选分辨率的最清晰视频 默认 视频格式偏好 当多种格式可供下载时的偏好选项 视频格式 音频转码 开始下载 关闭 关闭后不再显示 使用指引 打开应用设置 点击粘贴按钮,从剪贴板获取复制的视频链接。 随后点击下载按钮,在开始下载前,你可以修改用于本次下载的设置。 在下载记录页,查看与管理包括视频与音频在内的应用内下载。 在开始使用前,建议先对下载设置进行确认,并且将 yt-dlp 更新到最新版本。 下载播放列表 一次性下载播放列表中的多个视频 默认 深色主题、动态色彩、语言 确认此次下载的下载设置 下载 显示下载进度与完成信息 下载完成,轻按打开。 正在执行自定义命令… 为防止后台下载时系统结束进程,请在系统设置的电池管理中将此应用设置为“无限制”。 多线程下载 使用多线程下载 HLS/DASH 流视频 将同时使用 %d 个线程下载 HLS/DASH 流视频 选项 附加设置 未能从分享内容中匹配到视频链接 从分享内容获取视频链接 显示更多操作 下载通知 在系统通知中显示下载进度与完成信息 正在获取播放列表信息… 下载范围选择 指定要从播放列表\"%3$s\"下载的视频(序号范围:%1$d-%2$d)。 起始序号 结束序号 序号范围非法 正在下载播放列表(%1$d/%2$d)… 音频目录 下载目录 设置视频及音频文件的下载目录 下载到子目录 将文件保存到以各自字段命名的文件夹中 存储权限提示 无法下载到除 Download/ 及 Documents/ 以外的目录 后台下载设置 允许本应用在后台运行以在后台进行下载 Seal 正在进行下载… 出现不可预知的错误 翻译 在 Weblate 上帮助我们翻译此应用 输出路径模板 封装字幕 如可用将软字幕嵌入到视频中 新建命令模板 模板标签 移除? 从命令行模板中永久移除 \"%1$s\" 吗? 模板选择 编辑与管理命令模板 下载进行中,轻按取消… 下载任务被取消 GitHub 议题 提交错误报告或改进建议 信息已复制到剪贴板 等待开始 已完成 下载中 已取消 正在获取信息 打开文件 重新开始 发生错误 复制链接 复制报告 分辨率 视频文件大小 导出到剪贴板 从剪贴板导入 共导出了 %1$d 条模板 共导入了 %1$d 条模板 %1$d 下载任务 最近添加 %1$d 视频,%2$d 音频 从下载记录中永久删除 %1$d 条记录吗? 使用 SponsorBlock API 移除视频中的片段 指定要从视频中移除的片段类别 SponsorBlock 分类标记 检查更新 自动检查并更新到 GitHub 上的最新版本 当前版本已为最新 更新过程中出现错误 更新 使用 aria2c 下载器进行下载 Cookies 使用 Netscape 规范的 Cookies 文件进行下载 清除临时文件 删除临时目录下的所有临时文件 共清除了 %1$d 个临时文件 临时文件可用于恢复被取消的下载,是否要清除当前下载目录下的所有临时文件? \n \n你可以在 %1$s 访问这些文件 多选模式 无痕模式 停止保存下载记录 动态色彩 将壁纸颜色应用于应用主题 使用移动数据下载 允许使用计费网络进行下载 使用移动数据网络下载已在设置中被禁用 无法打开此文件,其可能已被移动或删除 网络 限速下载 限制最大下载速度 最高速率 高对比度深色主题 输入不合法 最低画质 不可用 文件格式、视频画质、字幕 Yt-dlp 版本、通知、隐私 限速下载、下载工具、cookies 禁用预览 在下载时不显示视频预览图 隐私 启用自定义命令 隐藏目录 将文件下载到隐藏目录中 裁剪封面 将音频中内嵌的封面图片裁切为正方形 选择要从播放列表 “%1$s” 中下载的视频 全选 已选中 %1$d 项 视频(无音轨) 推荐 格式选择 在开始下载前选择要下载的格式 生成新的 Cookies 启用 Cookies 删除 \"%1$s\" 的此条目?请注意这不会清理此站点的 cookies 文件。 部分选项在自定义命令模式下不可用 使用说明 从某些站点下载需要帐户验证信息。 点击“生成新的 Cookies”,输入网站的 URL,然后在浏览器页面使用你的账户登录,应用将会自动生成并在下载时输入认证信息。 Telegram 频道 Matrix 空间 SD 卡目录 自动生成的字幕 下载自动生成的字幕 一键下载 大部分视频平台将音频流与视频流分开传输,你可以同时选取仅音频格式与仅视频格式,将其合并为一个视频。 视频标题 视频作者 字幕 下载字幕 字幕语言 语言,封装字幕,自动字幕 复制日志 清除 编辑快捷命令 添加 快捷命令 编辑可用于撰写命令行模板的定制快捷方式。 任务列表 显示日志 日志 从视频中移除 SponsorBlock 段落会导致字幕时间轴错位。 为嵌入软字幕,视频将被封装到 MKV 容器中。你可以使用 VLC 播放器或其他兼容的应用播放内嵌软字幕的视频。 %.2f GB %.2f MB 分享 稳定 预览 安装预览版本提前体验新功能与变化。 \n \n预览版本可能不太稳定,如在使用的过程中遇到任何问题,请毫不犹豫向我们进行反馈,以帮助我们更好地优化应用。 更新频道 自动更新 启用自动更新 撤销 应用 剪辑视频 开始时间 结束时间 音频格式偏好 最低质量 无限制 音频质量 格式排序 当有多种音质可选时所要下载的音频码率 标题 使用 yt-dlp 的 -S 命令对格式进行排序 从设置导入 重命名 清除所有 Cookies 确定要删除储存在应用中的所有 Cookies 吗? 将临时文件储存在内部目录中 赞助 在 GitHub 上赞助以支持本应用 Seal 将永远保持免费与开源,如果你想要支持它的持续开发与改进,请考虑在 GitHub 上赞助我! 反馈 赞助 下载记录空空如也 测试 启用试验性功能吗? 在格式选择页面进行视频剪辑 该功能将使用 FFmpeg 以下载选定部分的视频,该功能仍处于试验阶段,剪辑不会完全准确,且并非所有格式都支持该功能,使用该功能时的下载速度将会显著减慢。 音频格式 %1$s 版本无法使用自动更新,如果你的设备并未安装 %1$s,或想要升级到测试版本以提前体验 Seal 中即将加入的新功能,请考虑 %2$s。 切换至 GitHub 版本 此功能不可用 感激不尽! 转换字幕格式 将字幕文件转换为另一格式 切分视频 视频会被切分为 %1$d 个片段 糟糕!出现了不可预知的错误 开发者的话 复制并退出 没有正在执行中的自定义命令任务 从链接下载视频文件 展开 新下载任务 开始 编辑命令模板 \"%1$s\" 代理设置 使用代理进行网络连接 兼容优先 质量优先 启用通知? 应用需要通知权限以发送有关下载状态与进度的通知。 禁用 点此设置下载目录 自定义命令目录 已禁用 目录选择 制定自定义命令模式下要使用的下载路径 优先下载 MP4(H.264) 格式的视频以分享到其他应用 优先下载 AV1、VP9 或 H.265 编码的视频以在兼容的应用中播放 下载类型 自定义 自动 命令行 格式偏好 了解更多信息 未知 %d 个项目 要从命令模板中移除 %1$s吗? 点击打开网页以刷新 cookies: 用户代理 导出到文件 yt-dlp 是一款强大的命令行视频下载工具。通过提供直观的图形用户界面(GUI)、 常见命令行的预设以及其他附加功能,Seal 使该工具更易使用。 \n \n对于 yt-dlp 的高级用法,Seal 允许你直接创建、保存并执行自定义的命令行模板,就像在命令行终端上操作一样。 \n \n使用自定义命令行时,多数 GUI 选项和功能会被禁用。 预设 输出模板 指定输出文件名的模板 清理下载存档? 永久性删除归档文件中的 %1$s? 将已下载视频的 ID 记录在一个归档文件中避免重复下载 下载存档 内嵌元数据 将元数据和视频缩略图内嵌到音频文件 必需的 显示全部 %1$d 项 保存 使用格式排序 编辑文件 只允许文件名包含特定字符来确保兼容性 限制文件名 网站 播放列表标题 下载将被另存为: 系统设置 强制使用 IPv4 使所有连接通过 IPv4 进行 保留字幕文件 允许一次 不允许 允许用移动网络数据下载吗? 始终允许 合并多个音频流 允许多个音频流被合并到单一文件 在下载中搜索 搜索 自动翻译的字幕 所有自动翻译的字幕都将在下载中可用。这些字幕可能不精确且难以理解。 下次下载时仍使用这些设置 使用先前所选 指定在自动格式选择中要下载哪些语言的字幕,语言名称间用英文逗号隔开。 重置 不,谢了 今后下载文件时将下载下列语言的字幕: 更新字幕语言? 在字幕中搜索 导出 导入 导出下载历史记录? 正在从下载历史记录中导出 %1$s。已下载的文件和偏好设置不会被备份。 完整备份 备份类型 文件 剪贴板 导入位置 导入下载历史记录? 已下载的文件不会被导入 您需要手动重新下载它们 下载历史 已将 %1$s 导入下载历史记录 重新下载 导出到 此视频已被下载。如果这不是预期的行为,请检查你的下载存档。 视频封装容器 封装视频至 MKV 容器,改进兼容性 共 %1$d 个 cookies,来自 %2$d 个站点 每天 每周 每月 继续 下载可用的最佳格式 首选 %1$s 使用格式偏好自动下载 编辑预设 所有语言 预设 播放列表 %d 个音频 %d 个视频 选择格式和字幕并进一步定制 已添加任务到队列 你会在这里找到下载文件 轻按下载按钮或分享视频链接到本应用来启动下载 已下载 全部 从 %1$d 个链接中选择 下载队列 显示导航抽屉 继续 删除 媒体信息 故障排除 问题跟踪器 修复常见问题并检查已知问题 遇到问题了?报告新问题前,请搜索我们的问题跟踪器。许多常见问题已在那里被解决并有记录。 保存的链接 添加新链接 添加到 %1$s ================================================ FILE: app/src/main/res/values-zh-rTW/strings.xml ================================================ 貼上 URL 下載並儲存音訊,而非影片 正在使用最新版本的 yt-dlp 連結不可為空 設定 下載 無法安裝最新版本的 yt-dlp。請確認您已連線至網際網路。 無法擷取影片資訊 一般 音訊 開啟連結 移除 刪除檔案 關於 版本、意見反應、自動更新 返回 版本 查看更新日誌和最新版本 最新版本 一般、格式、自訂命令 正在擷取影片資訊… 下載完成 存取遭拒 無法下載檔案 確認 介面語言 點擊以下載最新版本的 yt-dlp 取消 鳴謝 下載 設定介面語言 Yt-dlp 版本 現有的下載任務仍在執行 確定移除? 影片 已選取 自訂命令 來源與自由軟體 影片資料夾 儲存為音訊 另存影片縮圖 下載「%1$s」 在剪貼簿中沒有相符的 URL 確定要從下載記錄中移除「%1$s」嗎? 已複製連結到剪貼簿 查看 GitHub 存放庫和 README 使用自訂的範本來執行 yt-dlp 命令 命令範本 編輯 開始執行命令 顯示 深色主題、動態色彩、語言 深色主題 系統預設 開啟 關閉 取消 詳細資訊 進階 下載時顯示詳細資訊 另存縮圖 下載前配置偏好設定 下載前配置 調整本次下載的配置 Yt-dlp 使用參考 應用程式將自動加入輸出路徑和 URL。 轉換音訊格式 不轉換 轉換成 %1$s 格式 音訊重新編碼將造成品質下降和檔案大小增加。 最佳品質 當存在多種選項時限縮影片品質 不指定 (預設) 影片品質 影片格式 當提供多種選項時的偏好格式 開啟設定 下載播放清單 預設 檢查並管理應用程式內下載,包含影片檔和音訊檔。 檢查下載配置並確保在使用前擁有最新版本的 yt-dlp。 從播放清單中下載多項影片 下載 正在執行自訂命令… 多執行緒下載 影片連結 請將此應用程式的系統電池用量設定設為「無限制」以利背景下載。 顯示更多操作 正在從分享內容中讀取影片連結… %d 個執行緒將用於同時下載 DASH/HLS 原生影片。 下載通知 正在擷取播放清單資訊… 播放清單範圍 無效的索引範圍 正在下載播放清單 (%1$d/%2$d)… 通知下載檔案和進度資訊 起始編號 結束編號 音訊資料夾 下載目錄 儲存檔案到以相應欄位命名的資料夾 不支援 Download/ 和 Documents/ 以外的目錄 電池配置 Seal 正在下載… 未知錯誤 翻譯 在 Hosted Weblate 上協助翻譯此應用程式 路徑範本 新增範本 如有提供可用的軟字幕,則將其內嵌至影片中 儲存權限有已知問題 忽略此應用程式的電池最佳化設定以利背景下載 標籤 確定移除? 確定要從命令範例中移除「%1$s」嗎? 範本選取 提交錯誤報告或功能請求議題 已複製資訊到剪貼簿 等候開始 已完成 正在下載 正在擷取資訊 開啟檔案 重新啟動 錯誤 影片解析度 影片檔大小 下載 使用指南 貼上 縮圖 已複製錯誤報告到剪貼簿 影片格式偏好 選項 關閉 不再顯示 轉換 點擊 [貼上] 從您的剪貼簿取得影片連結。 然後調整設定後點擊 [下載]。 通知下載檔案和進度資訊 下載完成。輕觸以開啟。 其他設定 無法比對來自分享內容中的 URL 指定欲從「%3$s」播放清單中下載的影片項目範圍 (編號從 %1$d 到 %2$d)。 儲存到子目錄 選取影片檔和音訊檔的儲存位置 內嵌字幕 GitHub 議題 編輯和管理命令範本 正在下載… 已取消 已取消下載任務 複製連結 複製報告 以更多執行緒下載 M3U8/MPD 影片 從剪貼簿匯入 已匯出 %1$d 個範本 已匯入 %1$d 個範本 %1$d 項下載任務 最近新增 %1$d 個影片,%2$d 個音訊 匯出到剪貼簿 確定要移除 %1$d 項下載記錄嗎? 指定欲從影片檔中移除或標記的 SponsorBlock 類別 使用 SponsorBlock API 移除或標記影片中的片段 SponsorBlock 類別 檢查更新 已是最新版本 更新到最新版本失敗 更新 從暫存目錄中刪除全數暫存檔案 已刪除 %1$d 個暫存檔案 使用 aria2c 作為外部下載程式 使用 Netscape 格式的 Cookies 檔案進行下載 清除暫存檔案 自動檢查來自 GitHub 提供的更新 暫存檔案可用於繼續已取消的下載。確定要刪除全數暫存檔案嗎? \n \n您可以在 %1$s 中存取上述檔案 多選模式 停用下載記錄 無痕模式 允許使用計量付費網路進行媒體下載 「以行動數據進行下載」已設為停用 動態顏色 將桌布顏色套用至應用程式主題 以行動數據進行下載 無法開啟檔案 網際網路 最高速率 高對比深色主題 限制速率 限制最大下載速度 輸入無效 最低畫質 不可用 速率限制、下載程式、Cookies 下載時不顯示縮圖 停用預覽 隱私 使用自訂命令 隱藏目錄 將檔案儲存到隱藏目錄中 將內嵌封面裁剪至正方形 裁剪封面 檔案格式、影片畫質、字幕 Yt-dlp 版本、通知、播放清單 從「%1$s」播放清單中選取影片以進行下載 已選取 %1$d 項 全部選取 格式選取 在開始下載前選取下載格式 建議 影片 (無音訊) 產生新 cookies 部分選項在使用自訂命令時不可用 如何使用? Telegram 頻道 確定要刪除「%1$s」?請注意,此操作不會清除此網站的 cookies 檔案。 使用 Cookies 從某些網站下載時會需要帳戶驗證資訊。按一下 [產生新 Cookies],接著輸入網站 URL 並在瀏覽器中以個人帳戶登入,應用程式將產生下載時所需的必要資訊。 Matrix 空間 Cookies 大多數影音串流平台皆分別提供純音訊和純影片格式,可選取純音訊和純影片格式,並將其合併為單個影片。 清除 SD 卡資料夾 自動化字幕 下載自動產生的字幕 快速下載 影片標題範例文字 影片建立者範例文字 字幕 下載字幕 字幕語言 語言、內嵌字幕、自動化字幕 複製記錄檔 新增 編輯捷徑 捷徑 編輯可用於撰寫命令範本的自訂捷徑。 正在執行的任務 顯示記錄 記錄 當移除 SponsorBlock 區段時,可能導致字幕時間軸錯位。 為嵌入軟字幕,影片將重新混合至 MKV 容器中。請使用 VLC Media Player 或其他相容的應用程式,以觀賞嵌入軟字幕的影片。 %.2f GB %.2f MB 分享 穩定 預覽 安裝預先發行版組建以預覽最新功能和變更。 \n \n此版本可能會有不穩定的情況發生,如在使用過程中有任何問題,請不要吝嗇於給予回饋,以協助我們改進應用程式。 更新頻道 自動更新 啟用自動更新 套用 剪裁影片 開始時間 結束時間 捨棄 音訊格式偏好 無限制 最低位元速率 音訊品質 重新命名 匯入 當存在多種品質時限縮音訊位元速率 清除所有 Cookies 確定要刪除在應用程式中所有 Cookies 嗎? 格式排序 使用 yt-dlp 的 -S 選項對格式進行排序 標題 在內部目錄儲存暫存檔案 贊助 透過在 GitHub 上贊助以支持此應用程式 意見反應 贊助 Seal 永遠提供眾人免費使用並開放原始碼。若您悅納,請考慮在 GitHub 上贊助我! 沒有已下載的媒體 Beta 確定要啟用實驗性功能? 在格式選取頁中剪裁影片 音訊格式 使用此功能以將下載任務—— 選取的影片區段,委派予 FFmpeg。此功能仍在實驗性階段,剪裁並非完全精確且並非支援全部格式,下載速率可能較慢。 自動更新不適用於 %1$s 組建。如果您的裝置尚未安裝 %1$s,或想要預覽 Seal 即將推出的新功能,請考慮使用 %2$s。 切換至 GitHub 組建 確定 瞭解 此功能無法使用 沒有自訂命令的任務 來自開發者的訊息 非常感謝您! 從 URL 下載影片 影片將被分割成 %1$d 個章節 糟糕!發生錯誤 轉換字幕 轉換字幕至其他格式 分割影片 複製並離開 展開 新增下載任務 開始 編輯「%1$s」 傳統 品質 確定要啟用通知? 停用 輕觸以設定目錄 自訂命令目錄 資料夾選擇器 當使用自訂命令時指定輸出目錄 當分享至其他應用程式時偏好 MP4(H.264) 格式 使用 Proxy 連線至網際網路 請授予應用程式權限以推播下載狀態和進度的通知。 停用 在相容的應用程式裡進行觀賞時偏好 AV1, VP9 或 H.265 格式 Proxy 更新 yt-dlp 輕觸以開啟產生新 Cookies 的網頁: 下載類型 自訂 自動 命令 偏好格式 進一步了解 未知 確定要從命令範本中移除「%1$s」嗎? %d 項 使用者代理程式標頭 匯出成檔案 yt-dlp 是功能強大的下載影片命令列工具。Seal 透過提供直觀的 GUI、常見命令的預設值和其他額外功能,讓您更輕鬆地使用 yt-dlp。 \n \n如果您需要進階使用 yt-dlp,您可以在 Seal 中直接建立、儲存和執行自訂命令範本,就像在終端機一樣。 \n \n當您使用自訂命令時,大部分 GUI 選項和功能將會停用。 預設 輸出範本 為輸出檔案名稱指定範本 確定要清除下載封存? 確定要從下載封存中移除「%1$s」嗎? 將已下載的影片識別碼記錄至封存,以避免重複下載 下載封存 儲存 內嵌中繼資料 使用格式排序 必要 顯示所有項目 (%1$d) 編輯檔案 內嵌中繼資料和影片縮圖至音訊檔 限制檔案名稱需包含特定字元以確保相容性 限制命名 網站 播放清單標題 下載將另存於: 強制使用 IPv4 系統設定 讓所有連線皆透過 IPv4 進行 保留字幕檔案 允許一次 永久允許 不允許 確定要允許以行動數據下載? 合併多個音訊串流 允許將多個音訊串流合併成單一檔案 此影片已下載完成。如果這是未預期的行為,請檢查您的下載檔案。 匯入 匯出 確定要匯出下載記錄? 匯入於 正在從下載記錄中匯出 %1$s。已下載的檔案和偏好設定不會備份。 重新下載 自動翻譯字幕 所有自動翻譯的字幕都將在下載中可用。不過,字幕有可能不準確且難以理解。 記住本次下載設定 指定在自動格式選取中要下載哪些語言的字幕,語言名稱間以英文逗號分隔。 重設 不,謝謝 今後預設下載下列語言的字幕: 完整備份 使用先前所選 在字幕中搜尋 備份類型 匯出至 檔案 剪貼簿 確定要匯入下載記錄? 不會匯入已下載的檔案,請手動重新下載 下載歷史記錄 確定要更新字幕語言嗎? 已將 %1$s 匯入下載記錄 在下載中搜尋 搜尋 重新混合影片容器 共 %1$d 個 cookies,來自 %2$d 個網站 將影片重新混合至 MKV 容器以提高相容性 每天 每週 每月 所有語言 偏好 %1$s 預設 下載可用的最佳格式 %d 部影片 已將任務加入佇列 %d 段音訊 播放清單 繼續 選擇格式、字幕並進一步自訂 使用你的格式偏好自動下載 編輯預設 您可以在這裡找到您的下載內容 輕觸 [下載] 按鈕或分享影片連結到此應用程式以開始下載 已下載 全部 從 %1$d 個連結中選取 下載佇列 顯示導覽匣 繼續 刪除 媒體資訊 疑難排解 議題追蹤工具 修復常見錯誤並檢查已知問題 發生錯誤?在回報新問題之前,請先檢查議題追蹤工具。許多常見問題已經解決,並且記錄在案。 已儲存的連結 新增新連結 新增至 %1$s ================================================ FILE: app/src/main/res/xml/provider_paths.xml ================================================ ================================================ FILE: app/src/test/java/com/junkfood/seal/ExampleUnitTest.kt ================================================ package com.junkfood.seal import com.junkfood.seal.util.connectWithDelimiter import org.junit.Assert.assertEquals import org.junit.Test /** * Example local unit test, which will execute on the development machine (host). * * See [testing documentation](http://d.android.com/tools/testing). */ class ExampleUnitTest { @Test fun addition_isCorrect() { assertEquals(4, 2 + 2) } @Test fun testTextJoin() { assertEquals( connectWithDelimiter("123", "456", "789", delimiter = ","), listOf(123, 456, 789).joinToString(separator = ",") { it.toString() }, ) assertEquals(connectWithDelimiter(delimiter = ","), "") assertEquals(emptyList().joinToString(separator = ",") { it }, "") } } ================================================ FILE: build.gradle.kts ================================================ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.room) apply false } buildscript { repositories { mavenCentral() google() } } tasks.register("clean", Delete::class) { delete(rootProject.layout.buildDirectory) } ================================================ FILE: buildSrc/.gitignore ================================================ /build ================================================ FILE: buildSrc/build.gradle.kts ================================================ plugins { `kotlin-dsl` } repositories { mavenCentral() google() } dependencies { implementation(gradleApi()) implementation(localGroovy()) } kotlin { jvmToolchain(21) } ================================================ FILE: buildSrc/src/main/kotlin/Version.kt ================================================ sealed class Version(val major: Int, val minor: Int, val patch: Int, val build: Int = 0) { abstract val name: String abstract val code: Long class Alpha(versionMajor: Int, versionMinor: Int, versionPatch: Int, versionBuild: Int) : Version(versionMajor, versionMinor, versionPatch, versionBuild) { override val name: String get() = "${major}.${minor}.${patch}-alpha.$build" override val code: Long get() = major * MAJOR + minor * MINOR + patch * PATCH + build * BUILD + ALPHA } class Beta(versionMajor: Int, versionMinor: Int, versionPatch: Int, versionBuild: Int) : Version(versionMajor, versionMinor, versionPatch, versionBuild) { override val name: String get() = "${major}.${minor}.${patch}-beta.$build" override val code: Long get() = major * MAJOR + minor * MINOR + patch * PATCH + build * BUILD + BETA } class Stable(versionMajor: Int, versionMinor: Int, versionPatch: Int) : Version(versionMajor, versionMinor, versionPatch) { override val name: String get() = "${major}.${minor}.${patch}" override val code: Long get() = major * MAJOR + minor * MINOR + patch * PATCH + build * BUILD + STABLE } class ReleaseCandidate( versionMajor: Int, versionMinor: Int, versionPatch: Int, versionBuild: Int, ) : Version(versionMajor, versionMinor, versionPatch, versionBuild) { override val name: String get() = "${major}.${minor}.${patch}-rc.$build" override val code: Long get() = major * MAJOR + minor * MINOR + patch * PATCH + build * BUILD + RELEASE_CANDIDATE } } // private const val ABI = 1L private const val BUILD = 10L private const val VARIANT = 100L private const val PATCH = 10_000L private const val MINOR = 1_000_000L private const val MAJOR = 100_000_000L private const val STABLE = VARIANT * 4 private const val ALPHA = VARIANT * 1 private const val BETA = VARIANT * 2 private const val RELEASE_CANDIDATE = VARIANT * 3 val currentVersion: Version = Version.Alpha(versionMajor = 2, versionMinor = 0, versionPatch = 0, versionBuild = 5) ================================================ FILE: color/.gitignore ================================================ /build ================================================ FILE: color/build.gradle.kts ================================================ plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) alias(libs.plugins.compose.compiler) } java { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } kotlin { jvmToolchain(21) } android { compileSdk = 34 defaultConfig { minSdk = 21 } namespace = "com.junkfood.seal.color" compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_1_8 } buildTypes { release { proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) isMinifyEnabled = true } } } dependencies { api(platform(libs.androidx.compose.bom)) api(libs.androidx.compose.ui) api(libs.androidx.compose.runtime) api(libs.androidx.core.ktx) api(libs.androidx.compose.foundation) api(libs.androidx.compose.material3) } ================================================ FILE: color/proguard-rules.pro ================================================ -keep class com.kyant.monet.** { *; } -keep class io.material.hct.** { *; } -dontwarn java.lang.invoke.StringConcatFactory ================================================ FILE: color/src/main/java/com/kyant/monet/ColorSpec.kt ================================================ package com.kyant.monet data class ColorSpec( val chroma: (Double) -> Double = { it }, val hueShift: (Double) -> Double = { 0.0 } ) ================================================ FILE: color/src/main/java/com/kyant/monet/Monet.kt ================================================ package com.kyant.monet import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ColorScheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import com.kyant.monet.TonalPalettes.Companion.toTonalPalettes val LocalTonalPalettes = staticCompositionLocalOf { Color(0xFF007FAC).toTonalPalettes() } inline val Number.a1: Color @Composable get() = LocalTonalPalettes.current accent1 toDouble() inline val Number.a2: Color @Composable get() = LocalTonalPalettes.current accent2 toDouble() inline val Number.a3: Color @Composable get() = LocalTonalPalettes.current accent3 toDouble() inline val Number.n1: Color @Composable get() = LocalTonalPalettes.current neutral1 toDouble() inline val Number.n2: Color @Composable get() = LocalTonalPalettes.current neutral2 toDouble() @Composable fun dynamicColorScheme(isLight: Boolean = !isSystemInDarkTheme()): ColorScheme { return if (isLight) { lightColorScheme( background = 98.n1, inverseOnSurface = 95.n1, inversePrimary = 80.a1, inverseSurface = 20.n1, onBackground = 10.n1, onPrimary = 100.a1, onPrimaryContainer = 10.a1, onSecondary = 100.a2, onSecondaryContainer = 10.a2, onSurface = 10.n1, onSurfaceVariant = 30.n2, onTertiary = 100.a3, onTertiaryContainer = 10.a3, outline = 50.n2, outlineVariant = 80.n2, primary = 40.a1, primaryContainer = 90.a1, // scrim = 0.n1, secondary = 40.a2, secondaryContainer = 90.a2, surface = 98.n1, surfaceVariant = 90.n2, tertiary = 40.a3, tertiaryContainer = 90.a3, surfaceBright = 98.n1, surfaceDim = 87.n1, surfaceContainerLowest = 100.n1, surfaceContainerLow = 96.n1, surfaceContainer = 94.n1, surfaceContainerHigh = 92.n1, surfaceContainerHighest = 90.n1, ) } else { darkColorScheme( background = 6.n1, inverseOnSurface = 20.n1, inversePrimary = 40.a1, inverseSurface = 90.n1, onBackground = 90.n1, onPrimary = 20.a1, onPrimaryContainer = 90.a1, onSecondary = 20.a2, onSecondaryContainer = 90.a2, onSurface = 90.n1, onSurfaceVariant = 80.n2, onTertiary = 20.a3, onTertiaryContainer = 90.a3, outline = 60.n2, outlineVariant = 30.n2, primary = 80.a1, primaryContainer = 30.a1, // scrim = 0.n1, secondary = 80.a2, secondaryContainer = 30.a2, surface = 6.n1, surfaceVariant = 30.n2, tertiary = 80.a3, tertiaryContainer = 30.a3, surfaceBright = 24.n1, surfaceDim = 6.n1, surfaceContainerLowest = 4.n1, surfaceContainerLow = 10.n1, surfaceContainer = 12.n1, surfaceContainerHigh = 17.n1, surfaceContainerHighest = 22.n1, ) } } ================================================ FILE: color/src/main/java/com/kyant/monet/PaletteStyle.kt ================================================ @file:Suppress("unused") package com.kyant.monet class PaletteStyle( val accent1Spec: ColorSpec, val accent2Spec: ColorSpec, val accent3Spec: ColorSpec, val neutral1Spec: ColorSpec, val neutral2Spec: ColorSpec ) { companion object { private val VibrantSecondaryHueRotation = arrayOf( 0 to 18, 41 to 15, 61 to 10, 101 to 12, 131 to 15, 181 to 18, 251 to 15, 301 to 12, 360 to 12 ) private val VibrantTertiaryHueRotation = arrayOf( 0 to 35, 41 to 30, 61 to 20, 101 to 25, 131 to 30, 181 to 35, 251 to 30, 301 to 25, 360 to 25 ) private val ExpressiveSecondaryHueRotation = arrayOf( 0 to 45, 21 to 95, 51 to 45, 121 to 20, 151 to 45, 191 to 90, 271 to 45, 321 to 45, 360 to 45 ) private val ExpressiveTertiaryHueRotation = arrayOf( 0 to 120, 21 to 120, 51 to 120, 121 to 45, 151 to 20, 191 to 15, 271 to 20, 321 to 120, 360 to 120 ) val TonalSpot: PaletteStyle = PaletteStyle( accent1Spec = ColorSpec({ 36.0 }) { 0.0 }, accent2Spec = ColorSpec({ 16.0 }) { 0.0 }, accent3Spec = ColorSpec({ 24.0 }) { 60.0 }, neutral1Spec = ColorSpec({ 6.0 }) { 0.0 }, neutral2Spec = ColorSpec({ 8.0 }) { 0.0 } ) val Spritz: PaletteStyle = PaletteStyle( accent1Spec = ColorSpec({ 12.0 }) { 0.0 }, accent2Spec = ColorSpec({ 8.0 }) { 0.0 }, accent3Spec = ColorSpec({ 16.0 }) { 30.0 }, neutral1Spec = ColorSpec({ 2.0 }) { 0.0 }, neutral2Spec = ColorSpec({ 2.0 }) { 0.0 } ) val Vibrant: PaletteStyle = PaletteStyle( accent1Spec = ColorSpec({ 48.0 }) { 0.0 }, accent2Spec = ColorSpec({ 24.0 }) { it.hueRotation(VibrantSecondaryHueRotation) }, accent3Spec = ColorSpec({ 32.0 }) { it.hueRotation(VibrantTertiaryHueRotation) }, neutral1Spec = ColorSpec({ 10.0 }) { 0.0 }, neutral2Spec = ColorSpec({ 12.0 }) { 0.0 } ) val Expressive: PaletteStyle = PaletteStyle( accent1Spec = ColorSpec({ 40.0 }) { 240.0 }, accent2Spec = ColorSpec({ 24.0 }) { it.hueRotation(ExpressiveSecondaryHueRotation) }, accent3Spec = ColorSpec({ 32.0 }) { it.hueRotation(ExpressiveTertiaryHueRotation) }, neutral1Spec = ColorSpec({ 15.0 }) { 15.0 }, neutral2Spec = ColorSpec({ 12.0 }) { 15.0 } ) val Rainbow: PaletteStyle = PaletteStyle( accent1Spec = ColorSpec({ 48.0 }) { 0.0 }, accent2Spec = ColorSpec({ 16.0 }) { 0.0 }, accent3Spec = ColorSpec({ 24.0 }) { -60.0 }, neutral1Spec = ColorSpec({ 0.0 }) { 0.0 }, neutral2Spec = ColorSpec({ 0.0 }) { 0.0 } ) val FruitSalad: PaletteStyle = PaletteStyle( accent1Spec = ColorSpec({ 48.0 }) { -50.0 }, accent2Spec = ColorSpec({ 36.0 }) { -30.0 }, accent3Spec = ColorSpec({ 36.0 }) { 0.0 }, neutral1Spec = ColorSpec({ 10.0 }) { 0.0 }, neutral2Spec = ColorSpec({ 16.0 }) { 0.0 } ) val Content: PaletteStyle = PaletteStyle( accent1Spec = ColorSpec({ it * 1 }) { 0.0 }, accent2Spec = ColorSpec({ it / 3 }) { 0.0 }, accent3Spec = ColorSpec({ it * 2 / 3 }) { 60.0 }, neutral1Spec = ColorSpec({ it / 12 }) { 0.0 }, neutral2Spec = ColorSpec({ it / 6 }) { 0.0 } ) val Monochrome: PaletteStyle = PaletteStyle( accent1Spec = ColorSpec({ 0.0 }) { 0.0 }, accent2Spec = ColorSpec({ 0.0 }) { 0.0 }, accent3Spec = ColorSpec({ 0.0 }) { 0.0 }, neutral1Spec = ColorSpec({ 0.0 }) { 0.0 }, neutral2Spec = ColorSpec({ 0.0 }) { 0.0 }, ) private fun Double.hueRotation(list: Array>): Double { var i = 0 val size = list.size - 2 if (size >= 0) { while (true) { val i2 = i + 1 val intValue = (list[i2]).first.toFloat() when { list[i].first <= this && this < intValue -> { return (this + list[i].second.toDouble()).mod(360.0) } i == size -> break else -> i = i2 } } } return this } } } ================================================ FILE: color/src/main/java/com/kyant/monet/TonalPalettes.kt ================================================ package com.kyant.monet import androidx.compose.material3.ColorScheme import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import io.material.hct.Hct typealias TonalPalette = Map class TonalPalettes( val keyColor: Color, val style: PaletteStyle = PaletteStyle.TonalSpot, private val accent1: TonalPalette, private val accent2: TonalPalette, private val accent3: TonalPalette, private val neutral1: TonalPalette, private val neutral2: TonalPalette ) { infix fun accent1(tone: Double): Color = accent1.getOrElse(tone) { keyColor.transform(tone, style.accent1Spec) } infix fun accent2(tone: Double): Color = accent2.getOrElse(tone) { keyColor.transform(tone, style.accent2Spec) } infix fun accent3(tone: Double): Color = accent3.getOrElse(tone) { keyColor.transform(tone, style.accent3Spec) } infix fun neutral1(tone: Double): Color = neutral1.getOrElse(tone) { keyColor.transform(tone, style.neutral1Spec) } infix fun neutral2(tone: Double): Color = neutral2.getOrElse(tone) { keyColor.transform(tone, style.neutral2Spec) } companion object { private val M3TonalValues = doubleArrayOf( 0.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 85.0, 90.0, 95.0, 99.0, 100.0 ) private val M3SurfaceTonalValues = doubleArrayOf( 0.0, 4.0, 6.0, 10.0, 12.0, 17.0, 20.0, 22.0, 24.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 85.0, 87.0, 90.0, 92.0, 94.0, 95.0, 96.0, 98.0, 99.0, 100.0 ) fun Color.toTonalPalettes( style: PaletteStyle = PaletteStyle.TonalSpot, tonalValues: DoubleArray = M3TonalValues ): TonalPalettes = TonalPalettes( keyColor = this, style = style, accent1 = tonalValues.associateWith { transform(it, style.accent1Spec) }, accent2 = tonalValues.associateWith { transform(it, style.accent2Spec) }, accent3 = tonalValues.associateWith { transform(it, style.accent3Spec) }, neutral1 = M3SurfaceTonalValues.associateWith { transform(it, style.neutral1Spec) }, neutral2 = tonalValues.associateWith { transform(it, style.neutral2Spec) } ) private fun Color.toTonalPalette( tonalValues: DoubleArray = M3TonalValues ): TonalPalette = tonalValues.associateWith { transform(it, ColorSpec()) } /** * Convert an existing `ColorScheme` to an MD3 `TonalPalettes` * * Notice: This function is `PaletteStyle` independent * * @see ColorScheme * @see TonalPalettes */ fun ColorScheme.toTonalPalettes( tonalValues: DoubleArray = M3TonalValues ): TonalPalettes = TonalPalettes( keyColor = primary, accent1 = primary.toTonalPalette(tonalValues), accent2 = secondary.toTonalPalette(tonalValues), accent3 = tertiary.toTonalPalette(tonalValues), neutral1 = surface.toTonalPalette(M3SurfaceTonalValues), neutral2 = surfaceVariant.toTonalPalette(tonalValues), ) private fun Color.transform(tone: Double, spec: ColorSpec): Color { return Color(Hct.fromInt(this.toArgb()).apply { setTone(tone) setChroma(spec.chroma(this.chroma)) setHue(spec.hueShift(this.hue) + this.hue) }.toInt()) } } } ================================================ FILE: color/src/main/java/io/material/hct/Cam16.kt ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package io.material.hct import io.material.utils.ColorUtils import kotlin.math.abs import kotlin.math.atan2 import kotlin.math.cos import kotlin.math.expm1 import kotlin.math.hypot import kotlin.math.ln1p import kotlin.math.max import kotlin.math.pow import kotlin.math.sign import kotlin.math.sin import kotlin.math.sqrt /** * CAM16, a color appearance model. Colors are not just defined by their hex code, but rather, a hex * code and viewing conditions. * * * CAM16 instances also have coordinates in the CAM16-UCS space, called J*, a*, b*, or jstar, * astar, bstar in code. CAM16-UCS is included in the CAM16 specification, and should be used when * measuring distances between colors. * * * In traditional color spaces, a color can be identified solely by the observer's measurement of * the color. Color appearance models such as CAM16 also use information about the environment where * the color was observed, known as the viewing conditions. * * * For example, white under the traditional assumption of a midday sun white point is accurately * measured as a slightly chromatic blue by CAM16. (roughly, hue 203, chroma 3, lightness 100) */ class Cam16 /** * All of the CAM16 dimensions can be calculated from 3 of the dimensions, in the following * combinations: - {j or q} and {c, m, or s} and hue - jstar, astar, bstar Prefer using a static * method that constructs from 3 of those dimensions. This constructor is intended for those * methods to use to return all possible dimensions. * * @param hue for example, red, orange, yellow, green, etc. * @param chroma informally, colorfulness / color intensity. like saturation in HSL, except * perceptually accurate. * @param j lightness * @param q brightness; ratio of lightness to white point's lightness * @param m colorfulness * @param s saturation; ratio of chroma to white point's chroma * @param jstar CAM16-UCS J coordinate * @param astar CAM16-UCS a coordinate * @param bstar CAM16-UCS b coordinate */ private constructor( /** Hue in CAM16 */ // CAM16 color dimensions, see getters for documentation. val hue: Double, /** Chroma in CAM16 */ val chroma: Double, /** Lightness in CAM16 */ val j: Double, /** * Brightness in CAM16. * * * Prefer lightness, brightness is an absolute quantity. For example, a sheet of white paper is * much brighter viewed in sunlight than in indoor light, but it is the lightest object under any * lighting. */ val q: Double, /** * Colorfulness in CAM16. * * * Prefer chroma, colorfulness is an absolute quantity. For example, a yellow toy car is much * more colorful outside than inside, but it has the same chroma in both environments. */ val m: Double, /** * Saturation in CAM16. * * * Colorfulness in proportion to brightness. Prefer chroma, saturation measures colorfulness * relative to the color's own brightness, where chroma is colorfulness relative to white. */ val s: Double, /** Lightness coordinate in CAM16-UCS */ // Coordinates in UCS space. Used to determine color distance, like delta E equations in L*a*b*. val jstar: Double, /** a* coordinate in CAM16-UCS */ val astar: Double, /** b* coordinate in CAM16-UCS */ val bstar: Double ) { // Avoid allocations during conversion by pre-allocating an array. private val tempArray = doubleArrayOf(0.0, 0.0, 0.0) /** * CAM16 instances also have coordinates in the CAM16-UCS space, called J*, a*, b*, or jstar, * astar, bstar in code. CAM16-UCS is included in the CAM16 specification, and is used to measure * distances between colors. */ fun distance(other: Cam16): Double { val dJ = jstar - other.jstar val dA = astar - other.astar val dB = bstar - other.bstar val dEPrime = sqrt(dJ * dJ + dA * dA + dB * dB) return 1.41 * dEPrime.pow(0.63) } /** * ARGB representation of the color. Assumes the color was viewed in default viewing conditions, * which are near-identical to the default viewing conditions for sRGB. */ fun toInt(): Int { return viewed(ViewingConditions.Companion.DEFAULT) } /** * ARGB representation of the color, in defined viewing conditions. * * @param viewingConditions Information about the environment where the color will be viewed. * @return ARGB representation of color */ fun viewed(viewingConditions: ViewingConditions): Int { val xyz = xyzInViewingConditions(viewingConditions, tempArray) return ColorUtils.argbFromXyz(xyz[0], xyz[1], xyz[2]) } fun xyzInViewingConditions( viewingConditions: ViewingConditions, returnArray: DoubleArray? ): DoubleArray { val alpha = if (chroma == 0.0 || j == 0.0) 0.0 else chroma / sqrt( j / 100.0 ) val t = (alpha / (1.64 - 0.29.pow(viewingConditions.n)).pow(0.73)).pow(1.0 / 0.9) val hRad = hue.toRadians() val eHue = 0.25 * (cos(hRad + 2.0) + 3.8) val ac = (viewingConditions.aw * (j / 100.0).pow(1.0 / viewingConditions.c / viewingConditions.z)) val p1 = eHue * (50000.0 / 13.0) * viewingConditions.nc * viewingConditions.ncb val p2 = ac / viewingConditions.nbb val hSin = sin(hRad) val hCos = cos(hRad) val gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11.0 * t * hCos + 108.0 * t * hSin) val a = gamma * hCos val b = gamma * hSin val rA = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0 val gA = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0 val bA = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0 val rCBase = max(0.0, 27.13 * abs(rA) / (400.0 - abs(rA))) val rC = sign(rA) * (100.0 / viewingConditions.fl) * rCBase.pow(1.0 / 0.42) val gCBase = max(0.0, 27.13 * abs(gA) / (400.0 - abs(gA))) val gC = sign(gA) * (100.0 / viewingConditions.fl) * gCBase.pow(1.0 / 0.42) val bCBase = max(0.0, 27.13 * abs(bA) / (400.0 - abs(bA))) val bC = sign(bA) * (100.0 / viewingConditions.fl) * bCBase.pow(1.0 / 0.42) val rF = rC / viewingConditions.rgbD[0] val gF = gC / viewingConditions.rgbD[1] val bF = bC / viewingConditions.rgbD[2] val matrix = CAM16RGB_TO_XYZ val x = rF * matrix[0][0] + gF * matrix[0][1] + bF * matrix[0][2] val y = rF * matrix[1][0] + gF * matrix[1][1] + bF * matrix[1][2] val z = rF * matrix[2][0] + gF * matrix[2][1] + bF * matrix[2][2] return if (returnArray != null) { returnArray[0] = x returnArray[1] = y returnArray[2] = z returnArray } else { doubleArrayOf(x, y, z) } } companion object { // Transforms XYZ color space coordinates to 'cone'/'RGB' responses in CAM16. val XYZ_TO_CAM16RGB = arrayOf( doubleArrayOf(0.401288, 0.650173, -0.051461), doubleArrayOf(-0.250268, 1.204414, 0.045854), doubleArrayOf(-0.002079, 0.048952, 0.953127) ) // Transforms 'cone'/'RGB' responses in CAM16 to XYZ color space coordinates. val CAM16RGB_TO_XYZ = arrayOf( doubleArrayOf(1.8620678, -1.0112547, 0.14918678), doubleArrayOf(0.38752654, 0.62144744, -0.00897398), doubleArrayOf(-0.01584150, -0.03412294, 1.0499644) ) /** * Create a CAM16 color from a color, assuming the color was viewed in default viewing conditions. * * @param argb ARGB representation of a color. */ fun fromInt(argb: Int): Cam16 { return fromIntInViewingConditions(argb, ViewingConditions.Companion.DEFAULT) } /** * Create a CAM16 color from a color in defined viewing conditions. * * @param argb ARGB representation of a color. * @param viewingConditions Information about the environment where the color was observed. */ // The RGB => XYZ conversion matrix elements are derived scientific constants. While the values // may differ at runtime due to floating point imprecision, keeping the values the same, and // accurate, across implementations takes precedence. fun fromIntInViewingConditions(argb: Int, viewingConditions: ViewingConditions): Cam16 { // Transform ARGB int to XYZ val red = argb and 0x00ff0000 shr 16 val green = argb and 0x0000ff00 shr 8 val blue = argb and 0x000000ff val redL = ColorUtils.linearized(red) val greenL = ColorUtils.linearized(green) val blueL = ColorUtils.linearized(blue) val x = 0.41233895 * redL + 0.35762064 * greenL + 0.18051042 * blueL val y = 0.2126 * redL + 0.7152 * greenL + 0.0722 * blueL val z = 0.01932141 * redL + 0.11916382 * greenL + 0.95034478 * blueL return fromXyzInViewingConditions(x, y, z, viewingConditions) } fun fromXyzInViewingConditions( x: Double, y: Double, z: Double, viewingConditions: ViewingConditions ): Cam16 { // Transform XYZ to 'cone'/'rgb' responses val matrix = XYZ_TO_CAM16RGB val rT = x * matrix[0][0] + y * matrix[0][1] + z * matrix[0][2] val gT = x * matrix[1][0] + y * matrix[1][1] + z * matrix[1][2] val bT = x * matrix[2][0] + y * matrix[2][1] + z * matrix[2][2] // Discount illuminant val rD = viewingConditions.rgbD[0] * rT val gD = viewingConditions.rgbD[1] * gT val bD = viewingConditions.rgbD[2] * bT // Chromatic adaptation val rAF = (viewingConditions.fl * abs(rD) / 100.0).pow(0.42) val gAF = (viewingConditions.fl * abs(gD) / 100.0).pow(0.42) val bAF = (viewingConditions.fl * abs(bD) / 100.0).pow(0.42) val rA = sign(rD) * 400.0 * rAF / (rAF + 27.13) val gA = sign(gD) * 400.0 * gAF / (gAF + 27.13) val bA = sign(bD) * 400.0 * bAF / (bAF + 27.13) // redness-greenness val a = (11.0 * rA + -12.0 * gA + bA) / 11.0 // yellowness-blueness val b = (rA + gA - 2.0 * bA) / 9.0 // auxiliary components val u = (20.0 * rA + 20.0 * gA + 21.0 * bA) / 20.0 val p2 = (40.0 * rA + 20.0 * gA + bA) / 20.0 // hue val atan2 = atan2(b, a) val atanDegrees = atan2.toDegrees() val hue = if (atanDegrees < 0) atanDegrees + 360.0 else if (atanDegrees >= 360) atanDegrees - 360.0 else atanDegrees val hueRadians = hue.toRadians() // achromatic response to color val ac = p2 * viewingConditions.nbb // CAM16 lightness and brightness val j = (100.0 * (ac / viewingConditions.aw).pow(viewingConditions.c * viewingConditions.z)) val q = ((4.0 / viewingConditions.c) * sqrt(j / 100.0) * (viewingConditions.aw + 4.0) * viewingConditions.flRoot) // CAM16 chroma, colorfulness, and saturation. val huePrime = if (hue < 20.14) hue + 360 else hue val eHue = 0.25 * (cos(huePrime.toRadians() + 2.0) + 3.8) val p1 = 50000.0 / 13.0 * eHue * viewingConditions.nc * viewingConditions.ncb val t = p1 * hypot(a, b) / (u + 0.305) val alpha = (1.64 - 0.29.pow(viewingConditions.n)).pow(0.73) * t.pow(0.9) // CAM16 chroma, colorfulness, saturation val c = alpha * sqrt(j / 100.0) val m = c * viewingConditions.flRoot val s = 50.0 * sqrt(alpha * viewingConditions.c / (viewingConditions.aw + 4.0)) // CAM16-UCS components val jstar = (1.0 + 100.0 * 0.007) * j / (1.0 + 0.007 * j) val mstar = 1.0 / 0.0228 * ln1p(0.0228 * m) val astar = mstar * cos(hueRadians) val bstar = mstar * sin(hueRadians) return Cam16(hue, c, j, q, m, s, jstar, astar, bstar) } /** * @param j CAM16 lightness * @param c CAM16 chroma * @param h CAM16 hue */ fun fromJch(j: Double, c: Double, h: Double): Cam16 { return fromJchInViewingConditions(j, c, h, ViewingConditions.Companion.DEFAULT) } /** * @param j CAM16 lightness * @param c CAM16 chroma * @param h CAM16 hue * @param viewingConditions Information about the environment where the color was observed. */ private fun fromJchInViewingConditions( j: Double, c: Double, h: Double, viewingConditions: ViewingConditions ): Cam16 { val q = ((4.0 / viewingConditions.c) * sqrt(j / 100.0) * (viewingConditions.aw + 4.0) * viewingConditions.flRoot) val m = c * viewingConditions.flRoot val alpha = c / sqrt(j / 100.0) val s = 50.0 * sqrt(alpha * viewingConditions.c / (viewingConditions.aw + 4.0)) val hueRadians = h.toRadians() val jstar = (1.0 + 100.0 * 0.007) * j / (1.0 + 0.007 * j) val mstar = 1.0 / 0.0228 * ln1p(0.0228 * m) val astar = mstar * cos(hueRadians) val bstar = mstar * sin(hueRadians) return Cam16(h, c, j, q, m, s, jstar, astar, bstar) } /** * Create a CAM16 color from CAM16-UCS coordinates. * * @param jstar CAM16-UCS lightness. * @param astar CAM16-UCS a dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the Y * axis. * @param bstar CAM16-UCS b dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the X * axis. */ fun fromUcs(jstar: Double, astar: Double, bstar: Double): Cam16 { return fromUcsInViewingConditions( jstar, astar, bstar, ViewingConditions.Companion.DEFAULT ) } /** * Create a CAM16 color from CAM16-UCS coordinates in defined viewing conditions. * * @param jstar CAM16-UCS lightness. * @param astar CAM16-UCS a dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the Y * axis. * @param bstar CAM16-UCS b dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the X * axis. * @param viewingConditions Information about the environment where the color was observed. */ fun fromUcsInViewingConditions( jstar: Double, astar: Double, bstar: Double, viewingConditions: ViewingConditions ): Cam16 { val m = hypot(astar, bstar) val m2 = expm1(m * 0.0228) / 0.0228 val c = m2 / viewingConditions.flRoot var h = atan2(bstar, astar) * (180.0 / kotlin.math.PI) if (h < 0.0) { h += 360.0 } val j = jstar / (1.0 - (jstar - 100.0) * 0.007) return fromJchInViewingConditions(j, c, h, viewingConditions) } private inline fun Double.toRadians() = this * kotlin.math.PI / 180.0 private inline fun Double.toDegrees() = this * 180.0 / kotlin.math.PI } } ================================================ FILE: color/src/main/java/io/material/hct/Hct.kt ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package io.material.hct import io.material.utils.ColorUtils /** * A color system built using CAM16 hue and chroma, and L* from L*a*b*. * * * Using L* creates a link between the color system, contrast, and thus accessibility. Contrast * ratio depends on relative luminance, or Y in the XYZ color space. L*, or perceptual luminance can * be calculated from Y. * * * Unlike Y, L* is linear to human perception, allowing trivial creation of accurate color tones. * * * Unlike contrast ratio, measuring contrast in L* is linear, and simple to calculate. A * difference of 40 in HCT tone guarantees a contrast ratio >= 3.0, and a difference of 50 * guarantees a contrast ratio >= 4.5. */ /** * HCT, hue, chroma, and tone. A color system that provides a perceptually accurate color * measurement system that can also accurately render what colors will appear as in different * lighting environments. */ class Hct private constructor(argb: Int) { var hue = 0.0 private set var chroma = 0.0 private set var tone = 0.0 private set var argb = 0 init { setInternalState(argb) } fun toInt(): Int { return argb } /** * Set the hue of this color. Chroma may decrease because chroma has a different maximum for any * given hue and tone. * * @param newHue 0 <= newHue < 360; invalid values are corrected. */ fun setHue(newHue: Double) { setInternalState(HctSolver.solveToInt(newHue, chroma, tone)) } /** * Set the chroma of this color. Chroma may decrease because chroma has a different maximum for * any given hue and tone. * * @param newChroma 0 <= newChroma < ? */ fun setChroma(newChroma: Double) { setInternalState(HctSolver.solveToInt(hue, newChroma, tone)) } /** * Set the tone of this color. Chroma may decrease because chroma has a different maximum for any * given hue and tone. * * @param newTone 0 <= newTone <= 100; invalid valids are corrected. */ fun setTone(newTone: Double) { setInternalState(HctSolver.solveToInt(hue, chroma, newTone)) } /** * Translate a color into different ViewingConditions. * * * Colors change appearance. They look different with lights on versus off, the same color, as * in hex code, on white looks different when on black. This is called color relativity, most * famously explicated by Josef Albers in Interaction of Color. * * * In color science, color appearance models can account for this and calculate the appearance * of a color in different settings. HCT is based on CAM16, a color appearance model, and uses it * to make these calculations. * * * See ViewingConditions.make for parameters affecting color appearance. */ fun inViewingConditions(vc: ViewingConditions): Hct { // 1. Use CAM16 to find XYZ coordinates of color in specified VC. val cam16: Cam16 = Cam16.Companion.fromInt(toInt()) val viewedInVc = cam16.xyzInViewingConditions(vc, null) // 2. Create CAM16 of those XYZ coordinates in default VC. val recastInVc: Cam16 = Cam16.Companion.fromXyzInViewingConditions( viewedInVc[0], viewedInVc[1], viewedInVc[2], ViewingConditions.Companion.DEFAULT ) // 3. Create HCT from: // - CAM16 using default VC with XYZ coordinates in specified VC. // - L* converted from Y in XYZ coordinates in specified VC. return from( recastInVc.hue, recastInVc.chroma, ColorUtils.lstarFromY( viewedInVc[1] ) ) } private fun setInternalState(argb: Int) { this.argb = argb val cam: Cam16 = Cam16.Companion.fromInt(argb) hue = cam.hue chroma = cam.chroma tone = ColorUtils.lstarFromArgb(argb) } companion object { /** * Create an HCT color from hue, chroma, and tone. * * @param hue 0 <= hue < 360; invalid values are corrected. * @param chroma 0 <= chroma < ?; Informally, colorfulness. The color returned may be lower than * the requested chroma. Chroma has a different maximum for any given hue and tone. * @param tone 0 <= tone <= 100; invalid values are corrected. * @return HCT representation of a color in default viewing conditions. */ fun from(hue: Double, chroma: Double, tone: Double): Hct { val argb = HctSolver.solveToInt(hue, chroma, tone) return Hct(argb) } /** * Create an HCT color from a color. * * @param argb ARGB representation of a color. * @return HCT representation of a color in default viewing conditions */ fun fromInt(argb: Int): Hct { return Hct(argb) } } } ================================================ FILE: color/src/main/java/io/material/hct/HctSolver.kt ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // This file is automatically generated. Do not modify it. package io.material.hct import io.material.utils.ColorUtils import io.material.utils.MathUtils import kotlin.math.abs import kotlin.math.atan2 import kotlin.math.ceil import kotlin.math.cos import kotlin.math.floor import kotlin.math.max import kotlin.math.pow import kotlin.math.sin import kotlin.math.sqrt /** A class that solves the HCT equation. */ object HctSolver { val SCALED_DISCOUNT_FROM_LINRGB = arrayOf( doubleArrayOf( 0.001200833568784504, 0.002389694492170889, 0.0002795742885861124 ), doubleArrayOf( 0.0005891086651375999, 0.0029785502573438758, 0.0003270666104008398 ), doubleArrayOf( 0.00010146692491640572, 0.0005364214359186694, 0.0032979401770712076 ) ) val LINRGB_FROM_SCALED_DISCOUNT = arrayOf( doubleArrayOf( 1373.2198709594231, -1100.4251190754821, -7.278681089101213 ), doubleArrayOf( -271.815969077903, 559.6580465940733, -32.46047482791194 ), doubleArrayOf( 1.9622899599665666, -57.173814538844006, 308.7233197812385 ) ) val Y_FROM_LINRGB = doubleArrayOf(0.2126, 0.7152, 0.0722) val CRITICAL_PLANES = doubleArrayOf( 0.015176349177441876, 0.045529047532325624, 0.07588174588720938, 0.10623444424209313, 0.13658714259697685, 0.16693984095186062, 0.19729253930674434, 0.2276452376616281, 0.2579979360165119, 0.28835063437139563, 0.3188300904430532, 0.350925934958123, 0.3848314933096426, 0.42057480301049466, 0.458183274052838, 0.4976837250274023, 0.5391024159806381, 0.5824650784040898, 0.6277969426914107, 0.6751227633498623, 0.7244668422128921, 0.775853049866786, 0.829304845476233, 0.8848452951698498, 0.942497089126609, 1.0022825574869039, 1.0642236851973577, 1.1283421258858297, 1.1946592148522128, 1.2631959812511864, 1.3339731595349034, 1.407011200216447, 1.4823302800086415, 1.5599503113873272, 1.6398909516233677, 1.7221716113234105, 1.8068114625156377, 1.8938294463134073, 1.9832442801866852, 2.075074464868551, 2.1693382909216234, 2.2660538449872063, 2.36523901573795, 2.4669114995532007, 2.5710888059345764, 2.6777882626779785, 2.7870270208169257, 2.898822059350997, 3.0131901897720907, 3.1301480604002863, 3.2497121605402226, 3.3718988244681087, 3.4967242352587946, 3.624204428461639, 3.754355295633311, 3.887192587735158, 4.022731918402185, 4.160988767090289, 4.301978482107941, 4.445716283538092, 4.592217266055746, 4.741496401646282, 4.893568542229298, 5.048448422192488, 5.20615066083972, 5.3666897647573375, 5.5300801301023865, 5.696336044816294, 5.865471690767354, 6.037501145825082, 6.212438385869475, 6.390297286737924, 6.571091626112461, 6.7548350853498045, 6.941541251256611, 7.131223617812143, 7.323895587840543, 7.5195704746346665, 7.7182615035334345, 7.919981813454504, 8.124744458384042, 8.332562408825165, 8.543448553206703, 8.757415699253682, 8.974476575321063, 9.194643831691977, 9.417930041841839, 9.644347703669503, 9.873909240696694, 10.106627003236781, 10.342513269534024, 10.58158024687427, 10.8238400726681, 11.069304815507364, 11.317986476196008, 11.569896988756009, 11.825048221409341, 12.083451977536606, 12.345119996613247, 12.610063955123938, 12.878295467455942, 13.149826086772048, 13.42466730586372, 13.702830557985108, 13.984327217668513, 14.269168601521828, 14.55736596900856, 14.848930523210871, 15.143873411576273, 15.44220572664832, 15.743938506781891, 16.04908273684337, 16.35764934889634, 16.66964922287304, 16.985093187232053, 17.30399201960269, 17.62635644741625, 17.95219714852476, 18.281524751807332, 18.614349837764564, 18.95068293910138, 19.290534541298456, 19.633915083172692, 19.98083495742689, 20.331304511189067, 20.685334046541502, 21.042933821039977, 21.404114048223256, 21.76888489811322, 22.137256497705877, 22.50923893145328, 22.884842241736916, 23.264076429332462, 23.6469514538663, 24.033477234264016, 24.42366364919083, 24.817520537484558, 25.21505769858089, 25.61628489293138, 26.021211842414342, 26.429848230738664, 26.842203703840827, 27.258287870275353, 27.678110301598522, 28.10168053274597, 28.529008062403893, 28.96010235337422, 29.39497283293396, 29.83362889318845, 30.276079891419332, 30.722335150426627, 31.172403958865512, 31.62629557157785, 32.08401920991837, 32.54558406207592, 33.010999283389665, 33.4802739966603, 33.953417292456834, 34.430438229418264, 34.911345834551085, 35.39614910352207, 35.88485700094671, 36.37747846067349, 36.87402238606382, 37.37449765026789, 37.87891309649659, 38.38727753828926, 38.89959975977785, 39.41588851594697, 39.93615253289054, 40.460400508064545, 40.98864111053629, 41.520882981230194, 42.05713473317016, 42.597404951718396, 43.141702194811224, 43.6900349931913, 44.24241185063697, 44.798841244188324, 45.35933162437017, 45.92389141541209, 46.49252901546552, 47.065252796817916, 47.64207110610409, 48.22299226451468, 48.808024568002054, 49.3971762874833, 49.9904556690408, 50.587870934119984, 51.189430279724725, 51.79514187861014, 52.40501387947288, 53.0190544071392, 53.637271562750364, 54.259673423945976, 54.88626804504493, 55.517063457223934, 56.15206766869424, 56.79128866487574, 57.43473440856916, 58.08241284012621, 58.734331877617365, 59.39049941699807, 60.05092333227251, 60.715611475655585, 61.38457167773311, 62.057811747619894, 62.7353394731159, 63.417162620860914, 64.10328893648692, 64.79372614476921, 65.48848194977529, 66.18756403501224, 66.89098006357258, 67.59873767827808, 68.31084450182222, 69.02730813691093, 69.74813616640164, 70.47333615344107, 71.20291564160104, 71.93688215501312, 72.67524319850172, 73.41800625771542, 74.16517879925733, 74.9167682708136, 75.67278210128072, 76.43322770089146, 77.1981124613393, 77.96744375590167, 78.74122893956174, 79.51947534912904, 80.30219030335869, 81.08938110306934, 81.88105503125999, 82.67721935322541, 83.4778813166706, 84.28304815182372, 85.09272707154808, 85.90692527145302, 86.72564993000343, 87.54890820862819, 88.3767072518277, 89.2090541872801, 90.04595612594655, 90.88742016217518, 91.73345337380438, 92.58406282226491, 93.43925555268066, 94.29903859396902, 95.16341895893969, 96.03240364439274, 96.9059996312159, 97.78421388448044, 98.6670533535366, 99.55452497210776 ) /** * Sanitizes a small enough angle in radians. * * @param angle An angle in radians; must not deviate too much from 0. * @return A coterminal angle between 0 and 2pi. */ fun sanitizeRadians(angle: Double): Double { return (angle + kotlin.math.PI * 8) % (kotlin.math.PI * 2) } /** * Delinearizes an RGB component, returning a floating-point number. * * @param rgbComponent 0.0 <= rgb_component <= 100.0, represents linear R/G/B channel * @return 0.0 <= output <= 255.0, color channel converted to regular RGB space */ fun trueDelinearized(rgbComponent: Double): Double { val normalized = rgbComponent / 100.0 var delinearized = 0.0 delinearized = if (normalized <= 0.0031308) { normalized * 12.92 } else { 1.055 * normalized.pow(1.0 / 2.4) - 0.055 } return delinearized * 255.0 } fun chromaticAdaptation(component: Double): Double { val af = abs(component).pow(0.42) return MathUtils.signum(component) * 400.0 * af / (af + 27.13) } /** * Returns the hue of a linear RGB color in CAM16. * * @param linrgb The linear RGB coordinates of a color. * @return The hue of the color in CAM16, in radians. */ fun hueOf(linrgb: DoubleArray): Double { val scaledDiscount = MathUtils.matrixMultiply(linrgb, SCALED_DISCOUNT_FROM_LINRGB) val rA = chromaticAdaptation(scaledDiscount[0]) val gA = chromaticAdaptation(scaledDiscount[1]) val bA = chromaticAdaptation(scaledDiscount[2]) // redness-greenness val a = (11.0 * rA + -12.0 * gA + bA) / 11.0 // yellowness-blueness val b = (rA + gA - 2.0 * bA) / 9.0 return atan2(b, a) } fun areInCyclicOrder(a: Double, b: Double, c: Double): Boolean { val deltaAB = sanitizeRadians(b - a) val deltaAC = sanitizeRadians(c - a) return deltaAB < deltaAC } /** * Solves the lerp equation. * * @param source The starting number. * @param mid The number in the middle. * @param target The ending number. * @return A number t such that lerp(source, target, t) = mid. */ fun intercept(source: Double, mid: Double, target: Double): Double { return (mid - source) / (target - source) } fun lerpPoint(source: DoubleArray, t: Double, target: DoubleArray): DoubleArray { return doubleArrayOf( source[0] + (target[0] - source[0]) * t, source[1] + (target[1] - source[1]) * t, source[2] + (target[2] - source[2]) * t ) } /** * Intersects a segment with a plane. * * @param source The coordinates of point A. * @param coordinate The R-, G-, or B-coordinate of the plane. * @param target The coordinates of point B. * @param axis The axis the plane is perpendicular with. (0: R, 1: G, 2: B) * @return The intersection point of the segment AB with the plane R=coordinate, G=coordinate, or * B=coordinate */ fun setCoordinate( source: DoubleArray, coordinate: Double, target: DoubleArray, axis: Int ): DoubleArray { val t = intercept(source[axis], coordinate, target[axis]) return lerpPoint(source, t, target) } fun isBounded(x: Double): Boolean { return x in 0.0..100.0 } /** * Returns the nth possible vertex of the polygonal intersection. * * @param y The Y value of the plane. * @param n The zero-based index of the point. 0 <= n <= 11. * @return The nth possible vertex of the polygonal intersection of the y plane and the RGB cube, * in linear RGB coordinates, if it exists. If this possible vertex lies outside of the cube, * [-1.0, -1.0, -1.0] is returned. */ fun nthVertex(y: Double, n: Int): DoubleArray { val kR = Y_FROM_LINRGB[0] val kG = Y_FROM_LINRGB[1] val kB = Y_FROM_LINRGB[2] val coordA = if (n % 4 <= 1) 0.0 else 100.0 val coordB = if (n % 2 == 0) 0.0 else 100.0 return if (n < 4) { val r = (y - coordA * kG - coordB * kB) / kR if (isBounded(r)) { doubleArrayOf(r, coordA, coordB) } else { doubleArrayOf(-1.0, -1.0, -1.0) } } else if (n < 8) { val g = (y - coordB * kR - coordA * kB) / kG if (isBounded(g)) { doubleArrayOf(coordB, g, coordA) } else { doubleArrayOf(-1.0, -1.0, -1.0) } } else { val b = (y - coordA * kR - coordB * kG) / kB if (isBounded(b)) { doubleArrayOf(coordA, coordB, b) } else { doubleArrayOf(-1.0, -1.0, -1.0) } } } /** * Finds the segment containing the desired color. * * @param y The Y value of the color. * @param targetHue The hue of the color. * @return A list of two sets of linear RGB coordinates, each corresponding to an endpoint of the * segment containing the desired color. */ fun bisectToSegment(y: Double, targetHue: Double): Array { var left = doubleArrayOf(-1.0, -1.0, -1.0) var right = left var leftHue = 0.0 var rightHue = 0.0 var initialized = false var uncut = true for (n in 0..11) { val mid = nthVertex(y, n) if (mid[0] < 0) { continue } val midHue = hueOf(mid) if (!initialized) { left = mid right = mid leftHue = midHue rightHue = midHue initialized = true continue } if (uncut || areInCyclicOrder(leftHue, midHue, rightHue)) { uncut = false if (areInCyclicOrder(leftHue, targetHue, midHue)) { right = mid rightHue = midHue } else { left = mid leftHue = midHue } } } return arrayOf(left, right) } fun midpoint(a: DoubleArray, b: DoubleArray): DoubleArray { return doubleArrayOf( (a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2 ) } fun criticalPlaneBelow(x: Double): Int { return floor(x - 0.5).toInt() } fun criticalPlaneAbove(x: Double): Int { return ceil(x - 0.5).toInt() } /** * Finds a color with the given Y and hue on the boundary of the cube. * * @param y The Y value of the color. * @param targetHue The hue of the color. * @return The desired color, in linear RGB coordinates. */ fun bisectToLimit(y: Double, targetHue: Double): DoubleArray { val segment = bisectToSegment(y, targetHue) var left = segment[0] var leftHue = hueOf(left) var right = segment[1] for (axis in 0..2) { if (left[axis] != right[axis]) { var lPlane = -1 var rPlane = 255 if (left[axis] < right[axis]) { lPlane = criticalPlaneBelow( trueDelinearized( left[axis] ) ) rPlane = criticalPlaneAbove( trueDelinearized( right[axis] ) ) } else { lPlane = criticalPlaneAbove( trueDelinearized( left[axis] ) ) rPlane = criticalPlaneBelow( trueDelinearized( right[axis] ) ) } for (i in 0..7) { if (abs(rPlane - lPlane) <= 1) { break } else { val mPlane = floor((lPlane + rPlane) / 2.0).toInt() val midPlaneCoordinate = CRITICAL_PLANES[mPlane] val mid = setCoordinate(left, midPlaneCoordinate, right, axis) val midHue = hueOf(mid) if (areInCyclicOrder(leftHue, targetHue, midHue)) { right = mid rPlane = mPlane } else { left = mid leftHue = midHue lPlane = mPlane } } } } } return midpoint(left, right) } fun inverseChromaticAdaptation(adapted: Double): Double { val adaptedAbs = abs(adapted) val base = max(0.0, 27.13 * adaptedAbs / (400.0 - adaptedAbs)) return MathUtils.signum(adapted) * base.pow(1.0 / 0.42) } /** * Finds a color with the given hue, chroma, and Y. * * @param hueRadians The desired hue in radians. * @param chroma The desired chroma. * @param y The desired Y. * @return The desired color as a hexadecimal integer, if found; 0 otherwise. */ fun findResultByJ(hueRadians: Double, chroma: Double, y: Double): Int { // Initial estimate of j. var j = sqrt(y) * 11.0 // =========================================================== // Operations inlined from Cam16 to avoid repeated calculation // =========================================================== val viewingConditions: ViewingConditions = ViewingConditions.Companion.DEFAULT val tInnerCoeff = 1 / (1.64 - 0.29.pow(viewingConditions.n)).pow(0.73) val eHue = 0.25 * (cos(hueRadians + 2.0) + 3.8) val p1 = eHue * (50000.0 / 13.0) * viewingConditions.nc * viewingConditions.ncb val hSin = sin(hueRadians) val hCos = cos(hueRadians) for (iterationRound in 0..4) { // =========================================================== // Operations inlined from Cam16 to avoid repeated calculation // =========================================================== val jNormalized = j / 100.0 val alpha = if (chroma == 0.0 || j == 0.0) 0.0 else chroma / sqrt(jNormalized) val t = (alpha * tInnerCoeff).pow(1.0 / 0.9) val ac = (viewingConditions.aw * jNormalized.pow(1.0 / viewingConditions.c / viewingConditions.z)) val p2 = ac / viewingConditions.nbb val gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11 * t * hCos + 108.0 * t * hSin) val a = gamma * hCos val b = gamma * hSin val rA = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0 val gA = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0 val bA = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0 val rCScaled = inverseChromaticAdaptation(rA) val gCScaled = inverseChromaticAdaptation(gA) val bCScaled = inverseChromaticAdaptation(bA) val linrgb = MathUtils.matrixMultiply( doubleArrayOf(rCScaled, gCScaled, bCScaled), LINRGB_FROM_SCALED_DISCOUNT ) // =========================================================== // Operations inlined from Cam16 to avoid repeated calculation // =========================================================== if (linrgb[0] < 0 || linrgb[1] < 0 || linrgb[2] < 0) { return 0 } val kR = Y_FROM_LINRGB[0] val kG = Y_FROM_LINRGB[1] val kB = Y_FROM_LINRGB[2] val fnj = kR * linrgb[0] + kG * linrgb[1] + kB * linrgb[2] if (fnj <= 0) { return 0 } if (iterationRound == 4 || abs(fnj - y) < 0.002) { return if (linrgb[0] > 100.01 || linrgb[1] > 100.01 || linrgb[2] > 100.01) { 0 } else ColorUtils.argbFromLinrgb(linrgb) } // Iterates with Newton method, // Using 2 * fn(j) / j as the approximation of fn'(j) j -= (fnj - y) * j / (2 * fnj) } return 0 } /** * Finds an sRGB color with the given hue, chroma, and L*, if possible. * * @param hueDegrees The desired hue, in degrees. * @param chroma The desired chroma. * @param lstar The desired L*. * @return A hexadecimal representing the sRGB color. The color has sufficiently close hue, * chroma, and L* to the desired values, if possible; otherwise, the hue and L* will be * sufficiently close, and chroma will be maximized. */ fun solveToInt(hueDegrees: Double, chroma: Double, lstar: Double): Int { var hueDegrees = hueDegrees if (chroma < 0.0001 || lstar < 0.0001 || lstar > 99.9999) { return ColorUtils.argbFromLstar(lstar) } hueDegrees = MathUtils.sanitizeDegreesDouble(hueDegrees) val hueRadians = hueDegrees / 180 * kotlin.math.PI val y = ColorUtils.yFromLstar(lstar) val exactAnswer = findResultByJ(hueRadians, chroma, y) if (exactAnswer != 0) { return exactAnswer } val linrgb = bisectToLimit(y, hueRadians) return ColorUtils.argbFromLinrgb(linrgb) } /** * Finds an sRGB color with the given hue, chroma, and L*, if possible. * * @param hueDegrees The desired hue, in degrees. * @param chroma The desired chroma. * @param lstar The desired L*. * @return An CAM16 object representing the sRGB color. The color has sufficiently close hue, * chroma, and L* to the desired values, if possible; otherwise, the hue and L* will be * sufficiently close, and chroma will be maximized. */ fun solveToCam(hueDegrees: Double, chroma: Double, lstar: Double): Cam16 { return Cam16.Companion.fromInt(solveToInt(hueDegrees, chroma, lstar)) } } ================================================ FILE: color/src/main/java/io/material/hct/ViewingConditions.kt ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package io.material.hct import io.material.utils.ColorUtils import io.material.utils.MathUtils import kotlin.math.exp import kotlin.math.max import kotlin.math.pow import kotlin.math.sqrt /** * In traditional color spaces, a color can be identified solely by the observer's measurement of * the color. Color appearance models such as CAM16 also use information about the environment where * the color was observed, known as the viewing conditions. * * * For example, white under the traditional assumption of a midday sun white point is accurately * measured as a slightly chromatic blue by CAM16. (roughly, hue 203, chroma 3, lightness 100) * * * This class caches intermediate values of the CAM16 conversion process that depend only on * viewing conditions, enabling speed ups. */ class ViewingConditions /** * Parameters are intermediate values of the CAM16 conversion process. Their names are shorthand * for technical color science terminology, this class would not benefit from documenting them * individually. A brief overview is available in the CAM16 specification, and a complete overview * requires a color science textbook, such as Fairchild's Color Appearance Models. */ private constructor( val n: Double, val aw: Double, val nbb: Double, val ncb: Double, val c: Double, val nc: Double, val rgbD: DoubleArray, val fl: Double, val flRoot: Double, val z: Double ) { companion object { /** sRGB-like viewing conditions. */ val DEFAULT = defaultWithBackgroundLstar(50.0) /** * Create ViewingConditions from a simple, physically relevant, set of parameters. * * @param whitePoint White point, measured in the XYZ color space. default = D65, or sunny day * afternoon * @param adaptingLuminance The luminance of the adapting field. Informally, how bright it is in * the room where the color is viewed. Can be calculated from lux by multiplying lux by * 0.0586. default = 11.72, or 200 lux. * @param backgroundLstar The lightness of the area surrounding the color. measured by L* in * L*a*b*. default = 50.0 * @param surround A general description of the lighting surrounding the color. 0 is pitch dark, * like watching a movie in a theater. 1.0 is a dimly light room, like watching TV at home at * night. 2.0 means there is no difference between the lighting on the color and around it. * default = 2.0 * @param discountingIlluminant Whether the eye accounts for the tint of the ambient lighting, * such as knowing an apple is still red in green light. default = false, the eye does not * perform this process on self-luminous objects like displays. */ fun make( whitePoint: DoubleArray?, adaptingLuminance: Double, backgroundLstar: Double, surround: Double, discountingIlluminant: Boolean ): ViewingConditions { // A background of pure black is non-physical and leads to infinities that represent the idea // that any color viewed in pure black can't be seen. var backgroundLstar = backgroundLstar backgroundLstar = max(0.1, backgroundLstar) // Transform white point XYZ to 'cone'/'rgb' responses val matrix: Array = Cam16.Companion.XYZ_TO_CAM16RGB val rW = whitePoint!![0] * matrix[0][0] + whitePoint[1] * matrix[0][1] + whitePoint[2] * matrix[0][2] val gW = whitePoint[0] * matrix[1][0] + whitePoint[1] * matrix[1][1] + whitePoint[2] * matrix[1][2] val bW = whitePoint[0] * matrix[2][0] + whitePoint[1] * matrix[2][1] + whitePoint[2] * matrix[2][2] val f = 0.8 + surround / 10.0 val c = if (f >= 0.9) MathUtils.lerp( 0.59, 0.69, (f - 0.9) * 10.0 ) else MathUtils.lerp(0.525, 0.59, (f - 0.8) * 10.0) var d = if (discountingIlluminant) 1.0 else f * (1.0 - 1.0 / 3.6 * exp((-adaptingLuminance - 42.0) / 92.0)) d = MathUtils.clampDouble(0.0, 1.0, d) val rgbD = doubleArrayOf( d * (100.0 / rW) + 1.0 - d, d * (100.0 / gW) + 1.0 - d, d * (100.0 / bW) + 1.0 - d ) val k = 1.0 / (5.0 * adaptingLuminance + 1.0) val k4 = k * k * k * k val k4F = 1.0 - k4 val fl = k4 * adaptingLuminance + 0.1 * k4F * k4F * kotlin.math.cbrt(5.0 * adaptingLuminance) val n = ColorUtils.yFromLstar(backgroundLstar) / whitePoint[1] val z = 1.48 + sqrt(n) val nbb = 0.725 / n.pow(0.2) val rgbAFactors = doubleArrayOf( (fl * rgbD[0] * rW / 100.0).pow(0.42), (fl * rgbD[1] * gW / 100.0).pow(0.42), (fl * rgbD[2] * bW / 100.0).pow(0.42) ) val rgbA = doubleArrayOf( 400.0 * rgbAFactors[0] / (rgbAFactors[0] + 27.13), 400.0 * rgbAFactors[1] / (rgbAFactors[1] + 27.13), 400.0 * rgbAFactors[2] / (rgbAFactors[2] + 27.13) ) val aw = (2.0 * rgbA[0] + rgbA[1] + 0.05 * rgbA[2]) * nbb return ViewingConditions( n, aw, nbb, nbb, c, f, rgbD, fl, fl.pow(0.25), z ) } /** * Create sRGB-like viewing conditions with a custom background lstar. * * * Default viewing conditions have a lstar of 50, midgray. */ fun defaultWithBackgroundLstar(lstar: Double): ViewingConditions { return make( ColorUtils.whitePointD65(), 200.0 / kotlin.math.PI * ColorUtils.yFromLstar(50.0) / 100f, lstar, 2.0, false ) } } } ================================================ FILE: color/src/main/java/io/material/utils/ColorUtils.kt ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // This file is automatically generated. Do not modify it. package io.material.utils import kotlin.math.pow import kotlin.math.roundToLong /** * Color science utilities. * * * Utility methods for color science constants and color space conversions that aren't HCT or * CAM16. */ object ColorUtils { val SRGB_TO_XYZ = arrayOf( doubleArrayOf(0.41233895, 0.35762064, 0.18051042), doubleArrayOf(0.2126, 0.7152, 0.0722), doubleArrayOf(0.01932141, 0.11916382, 0.95034478) ) val XYZ_TO_SRGB = arrayOf( doubleArrayOf( 3.2413774792388685, -1.5376652402851851, -0.49885366846268053 ), doubleArrayOf( -0.9691452513005321, 1.8758853451067872, 0.04156585616912061 ), doubleArrayOf( 0.05562093689691305, -0.20395524564742123, 1.0571799111220335 ) ) val WHITE_POINT_D65 = doubleArrayOf(95.047, 100.0, 108.883) /** Converts a color from RGB components to ARGB format. */ fun argbFromRgb(red: Int, green: Int, blue: Int): Int { return 255 shl 24 or (red and 255 shl 16) or (green and 255 shl 8) or (blue and 255) } /** Converts a color from linear RGB components to ARGB format. */ fun argbFromLinrgb(linrgb: DoubleArray?): Int { val r = delinearized(linrgb!![0]) val g = delinearized(linrgb[1]) val b = delinearized(linrgb[2]) return argbFromRgb(r, g, b) } /** Returns the alpha component of a color in ARGB format. */ fun alphaFromArgb(argb: Int): Int { return argb shr 24 and 255 } /** Returns the red component of a color in ARGB format. */ fun redFromArgb(argb: Int): Int { return argb shr 16 and 255 } /** Returns the green component of a color in ARGB format. */ fun greenFromArgb(argb: Int): Int { return argb shr 8 and 255 } /** Returns the blue component of a color in ARGB format. */ fun blueFromArgb(argb: Int): Int { return argb and 255 } /** Returns whether a color in ARGB format is opaque. */ fun isOpaque(argb: Int): Boolean { return alphaFromArgb(argb) >= 255 } /** Converts a color from ARGB to XYZ. */ fun argbFromXyz(x: Double, y: Double, z: Double): Int { val matrix = XYZ_TO_SRGB val linearR = matrix[0][0] * x + matrix[0][1] * y + matrix[0][2] * z val linearG = matrix[1][0] * x + matrix[1][1] * y + matrix[1][2] * z val linearB = matrix[2][0] * x + matrix[2][1] * y + matrix[2][2] * z val r = delinearized(linearR) val g = delinearized(linearG) val b = delinearized(linearB) return argbFromRgb(r, g, b) } /** Converts a color from XYZ to ARGB. */ fun xyzFromArgb(argb: Int): DoubleArray? { val r = linearized(redFromArgb(argb)) val g = linearized(greenFromArgb(argb)) val b = linearized(blueFromArgb(argb)) return MathUtils.matrixMultiply(doubleArrayOf(r, g, b), SRGB_TO_XYZ) } /** Converts a color represented in Lab color space into an ARGB integer. */ fun argbFromLab(l: Double, a: Double, b: Double): Int { val whitePoint = WHITE_POINT_D65 val fy = (l + 16.0) / 116.0 val fx = a / 500.0 + fy val fz = fy - b / 200.0 val xNormalized = labInvf(fx) val yNormalized = labInvf(fy) val zNormalized = labInvf(fz) val x = xNormalized * whitePoint[0] val y = yNormalized * whitePoint[1] val z = zNormalized * whitePoint[2] return argbFromXyz(x, y, z) } /** * Converts a color from ARGB representation to L*a*b* representation. * * @param argb the ARGB representation of a color * @return a Lab object representing the color */ fun labFromArgb(argb: Int): DoubleArray { val linearR = linearized(redFromArgb(argb)) val linearG = linearized(greenFromArgb(argb)) val linearB = linearized(blueFromArgb(argb)) val matrix = SRGB_TO_XYZ val x = matrix[0][0] * linearR + matrix[0][1] * linearG + matrix[0][2] * linearB val y = matrix[1][0] * linearR + matrix[1][1] * linearG + matrix[1][2] * linearB val z = matrix[2][0] * linearR + matrix[2][1] * linearG + matrix[2][2] * linearB val whitePoint = WHITE_POINT_D65 val xNormalized = x / whitePoint[0] val yNormalized = y / whitePoint[1] val zNormalized = z / whitePoint[2] val fx = labF(xNormalized) val fy = labF(yNormalized) val fz = labF(zNormalized) val l = 116.0 * fy - 16 val a = 500.0 * (fx - fy) val b = 200.0 * (fy - fz) return doubleArrayOf(l, a, b) } /** * Converts an L* value to an ARGB representation. * * @param lstar L* in L*a*b* * @return ARGB representation of grayscale color with lightness matching L* */ fun argbFromLstar(lstar: Double): Int { val y = yFromLstar(lstar) val component = delinearized(y) return argbFromRgb(component, component, component) } /** * Computes the L* value of a color in ARGB representation. * * @param argb ARGB representation of a color * @return L*, from L*a*b*, coordinate of the color */ fun lstarFromArgb(argb: Int): Double { val y = xyzFromArgb(argb)!![1] return 116.0 * labF(y / 100.0) - 16.0 } /** * Converts an L* value to a Y value. * * * L* in L*a*b* and Y in XYZ measure the same quantity, luminance. * * * L* measures perceptual luminance, a linear scale. Y in XYZ measures relative luminance, a * logarithmic scale. * * @param lstar L* in L*a*b* * @return Y in XYZ */ fun yFromLstar(lstar: Double): Double { return 100.0 * labInvf((lstar + 16.0) / 116.0) } /** * Converts a Y value to an L* value. * * * L* in L*a*b* and Y in XYZ measure the same quantity, luminance. * * * L* measures perceptual luminance, a linear scale. Y in XYZ measures relative luminance, a * logarithmic scale. * * @param y Y in XYZ * @return L* in L*a*b* */ fun lstarFromY(y: Double): Double { return labF(y / 100.0) * 116.0 - 16.0 } /** * Linearizes an RGB component. * * @param rgbComponent 0 <= rgb_component <= 255, represents R/G/B channel * @return 0.0 <= output <= 100.0, color channel converted to linear RGB space */ fun linearized(rgbComponent: Int): Double { val normalized = rgbComponent / 255.0 return if (normalized <= 0.040449936) { normalized / 12.92 * 100.0 } else { ((normalized + 0.055) / 1.055).pow(2.4) * 100.0 } } /** * Delinearizes an RGB component. * * @param rgbComponent 0.0 <= rgb_component <= 100.0, represents linear R/G/B channel * @return 0 <= output <= 255, color channel converted to regular RGB space */ fun delinearized(rgbComponent: Double): Int { val normalized = rgbComponent / 100.0 var delinearized = 0.0 delinearized = if (normalized <= 0.0031308) { normalized * 12.92 } else { 1.055 * normalized.pow(1.0 / 2.4) - 0.055 } return MathUtils.clampInt(0, 255, (delinearized * 255.0).roundToLong().toInt()) } /** * Returns the standard white point; white on a sunny day. * * @return The white point */ fun whitePointD65(): DoubleArray { return WHITE_POINT_D65 } fun labF(t: Double): Double { val e = 216.0 / 24389.0 val kappa = 24389.0 / 27.0 return if (t > e) { t.pow(1.0 / 3.0) } else { (kappa * t + 16) / 116 } } fun labInvf(ft: Double): Double { val e = 216.0 / 24389.0 val kappa = 24389.0 / 27.0 val ft3 = ft * ft * ft return if (ft3 > e) { ft3 } else { (116 * ft - 16) / kappa } } } ================================================ FILE: color/src/main/java/io/material/utils/MathUtils.kt ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // This file is automatically generated. Do not modify it. package io.material.utils import kotlin.math.abs /** Utility methods for mathematical operations. */ object MathUtils { /** * The signum function. * * @return 1 if num > 0, -1 if num < 0, and 0 if num = 0 */ fun signum(num: Double): Int { return if (num < 0) { -1 } else if (num == 0.0) { 0 } else { 1 } } /** * The linear interpolation function. * * @return start if amount = 0 and stop if amount = 1 */ fun lerp(start: Double, stop: Double, amount: Double): Double { return (1.0 - amount) * start + amount * stop } /** * Clamps an integer between two integers. * * @return input when min <= input <= max, and either min or max otherwise. */ fun clampInt(min: Int, max: Int, input: Int): Int { if (input < min) { return min } else if (input > max) { return max } return input } /** * Clamps an integer between two floating-point numbers. * * @return input when min <= input <= max, and either min or max otherwise. */ fun clampDouble(min: Double, max: Double, input: Double): Double { if (input < min) { return min } else if (input > max) { return max } return input } /** * Sanitizes a degree measure as an integer. * * @return a degree measure between 0 (inclusive) and 360 (exclusive). */ fun sanitizeDegreesInt(degrees: Int): Int { var degrees = degrees degrees %= 360 if (degrees < 0) { degrees += 360 } return degrees } /** * Sanitizes a degree measure as a floating-point number. * * @return a degree measure between 0.0 (inclusive) and 360.0 (exclusive). */ fun sanitizeDegreesDouble(degrees: Double): Double { var degrees = degrees degrees %= 360.0 if (degrees < 0) { degrees += 360.0 } return degrees } /** * Sign of direction change needed to travel from one angle to another. * * * For angles that are 180 degrees apart from each other, both directions have the same travel * distance, so either direction is shortest. The value 1.0 is returned in this case. * * @param from The angle travel starts from, in degrees. * @param to The angle travel ends at, in degrees. * @return -1 if decreasing from leads to the shortest travel distance, 1 if increasing from leads * to the shortest travel distance. */ fun rotationDirection(from: Double, to: Double): Double { val increasingDifference = sanitizeDegreesDouble(to - from) return if (increasingDifference <= 180.0) 1.0 else -1.0 } /** Distance of two points on a circle, represented using degrees. */ fun differenceDegrees(a: Double, b: Double): Double { return 180.0 - abs(abs(a - b) - 180.0) } /** Multiplies a 1x3 row vector with a 3x3 matrix. */ fun matrixMultiply(row: DoubleArray, matrix: Array): DoubleArray { val a = row[0] * matrix[0][0] + row[1] * matrix[0][1] + row[2] * matrix[0][2] val b = row[0] * matrix[1][0] + row[1] * matrix[1][1] + row[2] * matrix[1][2] val c = row[0] * matrix[2][0] + row[1] * matrix[2][1] + row[2] * matrix[2][2] return doubleArrayOf(a, b, c) } } ================================================ FILE: color/src/main/java/io/material/utils/StringUtils.kt ================================================ /* * Copyright 2021 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package io.material.utils /** Utility methods for string representations of colors. */ internal object StringUtils { /** * Hex string representing color, ex. #ff0000 for red. * * @param argb ARGB representation of a color. */ fun hexFromArgb(argb: Int): String { val red = ColorUtils.redFromArgb(argb) val blue = ColorUtils.blueFromArgb(argb) val green = ColorUtils.greenFromArgb(argb) return String.format("#%02x%02x%02x", red, green, blue) } } ================================================ FILE: fastlane/metadata/android/ar-SA/full_description.txt ================================================ الميزات - تحميل مقاطع الفيديو والملفات الصوتية من منصات الفيديو المدعومة من yt-dlp - تضمين البيانات الوصفية وصورة الفيديو المصغرة في ملفات الصوت المستخرجة التي يدعمها mutagen. - تنزيل جميع مقاطع الفيديو في قائمة التشغيل بنقرة واحدة. - استخدم aria2c المدمج كبرنامج تنزيل خارجي لجميع التنزيلات الخاصة بك - دمج ملفات الترجمة في مقاطع الفيديو التي تم تنزيلها. - تنفيذ أوامر yt-dlp المخصصة باستخدام القوالب. - إدارة التنزيلات داخل التطبيق وقوالب الأوامر المخصصة. - سهل الاستخدام. - واجهة مستخدم بنمط تصميم Material Design 3 ، مع سمة ألوان ديناميكية Dynamic color. ================================================ FILE: fastlane/metadata/android/ar-SA/short_description.txt ================================================ برنامج لتنزيل ملفات الصوت والفيديو مصمم باستخدام Material You ================================================ FILE: fastlane/metadata/android/ar-SA/title.txt ================================================ Seal ================================================ FILE: fastlane/metadata/android/bn/short_description.txt ================================================ ম্যাটেরিয়াল ইউয়ের সাথে ডিজাইন করা অডিও/ভিডিও ডাউনলোডার ================================================ FILE: fastlane/metadata/android/bn/title.txt ================================================ সিল ================================================ FILE: fastlane/metadata/android/de-DE/changelogs/10320.txt ================================================ Verbesserungen der Zugänglichkeit: Entfernung von redundanter Semantik und Optimierung der Komponenten für TalkBack-Screenreader Fehlerbehebung: App stürzt aufgrund des Statusleistensymbols ab MP4-Formatpräferenz funktioniert manchmal nicht wie erwartet ================================================ FILE: fastlane/metadata/android/de-DE/changelogs/10330.txt ================================================ Funktionsupdate: Privater Modus: Vorschaubild und Downloadverlauf deaktivieren Weißrussische Übersetzungen hinzugefügt von @Kekich-dev ================================================ FILE: fastlane/metadata/android/de-DE/changelogs/10340.txt ================================================ Funktionserweiterung: Dynamische Farbe für Android 12+ aktivierbar Fehlerbehebung: Dropdown-Menü wird bei der Auswahl der Videoauflösung nicht angezeigt Kroatische Übersetzungen von @ElizabethWega hinzugefügt ================================================ FILE: fastlane/metadata/android/de-DE/changelogs/10350.txt ================================================ Funktions-Update: Sofortige Anzeige des Download-Dialogs nach Freigabe der Url Verhindern des Herunterladens bei gebührenpflichtigen Netzwerken Fehlerbehebung: Unerwartetes Verhalten beim Öffnen gelöschter Dateien Drehen des Geräts führt dazu, dass Dialoge wieder angezeigt werden ================================================ FILE: fastlane/metadata/android/de-DE/full_description.txt ================================================ Funktionen Herunterladen von Videos und Audiodateien von Videoplattformen, die von yt-dlp unterstützt werden Einbetten von Metadaten und Videovorschaubildern in extrahierte Audiodateien Download aller Videos in der Wiedergabeliste mit einem Klick Nutzung des eingebetteten Aria2c als externen Downloader für alle Downloads Untertitel in heruntergeladene Videos einbetten Ausführen von benutzerdefinierten yt-dlp-Befehlen mit Vorlagen Verwalten von In-App-Downloads und benutzerdefinierte Befehlsvorlagen Einfach zu bedienen und benutzerfreundlich Benutzeroberfläche im Material Design 3 Stil mit dynamischem Farbdesigns ================================================ FILE: fastlane/metadata/android/de-DE/short_description.txt ================================================ Video-/Audio-Downloader, gestaltet und designt mit Material You ================================================ FILE: fastlane/metadata/android/de-DE/title.txt ================================================ Seal ================================================ FILE: fastlane/metadata/android/en-US/changelogs/10704.txt ================================================ Feature Update: Select from the formats of videos to download Built-in WebView to generate cookies for downloading Running custom commands in parallel Translation Update: Add translations for Norwegian Nynorsk, Azerbaijani, and Punjabi For full changelogs, please refer to GitHub ================================================ FILE: fastlane/metadata/android/en-US/changelogs/10714.txt ================================================ Bug fixes and UI Improvements For full changelogs, please refer to GitHub ================================================ FILE: fastlane/metadata/android/en-US/changelogs/10724.txt ================================================ Bug Fix: Permission denied error when trying to access termux directory by accident App crashes below API 26 (Android 8) ================================================ FILE: fastlane/metadata/android/en-US/changelogs/10734.txt ================================================ Bug fixes and UI Improvements For full changelogs, please refer to GitHub ================================================ FILE: fastlane/metadata/android/en-US/changelogs/10804.txt ================================================ UI Improvements & Feature Updates: Download to SD card Quick download Task dashboard, log page and custom shortcuts for custom commands ... For full changelogs, please refer to GitHub ================================================ FILE: fastlane/metadata/android/en-US/changelogs/10814.txt ================================================ Fixed - App crashes when downloading in private mode - Unexpected ImeActions in TextFields - Disable SD card download when the directory is not set - Localized strings for file size texts ================================================ FILE: fastlane/metadata/android/en-US/changelogs/10824.txt ================================================ ### Fixed - Trimmed ASCII characters filename - Unexpected error when downloading multiple video to SD card with quick download - Error when cropping vertical thumbnails as artwork ================================================ FILE: fastlane/metadata/android/en-US/full_description.txt ================================================ Features Download videos and audio files from video platforms supported by yt-dlp Embed metadata and video thumbnail into extracted audio files Download all videos in the playlist with one click Use embedded aria2c as external downloader for all your downloads Embed subtitles into downloaded videos Execute custom yt-dlp commands with templates Manage in-app downloads and custom command templates Easy to use and user-friendly Material Design 3 style UI, with dynamic color theme ================================================ FILE: fastlane/metadata/android/en-US/short_description.txt ================================================ Video/Audio downloader designed and themed with Material You ================================================ FILE: fastlane/metadata/android/en-US/title.txt ================================================ Seal ================================================ FILE: fastlane/metadata/android/es/changelogs/10320.txt ================================================ Mejoras en la accesibilidad: Eliminación de la semántica redundante y optimización de los componentes para el lector de pantalla TalkBack Corrección de errores: La aplicación se bloqueaba debido al icono de la barra de estado La preferencia de formato MP4 a veces no funcionaba como se esperaba ================================================ FILE: fastlane/metadata/android/es/changelogs/10330.txt ================================================ Actualización de características: Modo privado: Deasctiva el historial de descargas y el almacenamiento de carátulas/miniaturas Añadido el idioma Bielorruso por @Kekich-dev ================================================ FILE: fastlane/metadata/android/es/changelogs/10340.txt ================================================ Actualización de características: Habilitar el color dinámico para Android 12+ Corrección de errores: El menú desplegable no se muestra al seleccionar la resolución de vídeo Añadir traducciones al croata por @ElizabethWega ================================================ FILE: fastlane/metadata/android/es/full_description.txt ================================================ Características Descargar vídeos y archivos de audio desde las plataformas soportadas por yt-dlp Incruste de metadatos y miniaturas de video en archivos de audio descargados Descarga de todos los vídeos de una lista de reproducción con un solo click Usar la librería incrustada aria2c como descargador externo Subtítulos integrados en los vídeos descargados Ejecutar comandos personalizados de yt-dlp con plantillas Manejar las descargas de dentro de la aplicación y las plantillas de comando personalizadas Fácil de usar y muy intuitiva Diseño de interfaz hecho con Material Design 3 y con uso de los colores dinámicos ================================================ FILE: fastlane/metadata/android/es/short_description.txt ================================================ Descargador de audio/video diseñado y tematizado con Material You ================================================ FILE: fastlane/metadata/android/es/title.txt ================================================ Seal ================================================ FILE: fastlane/metadata/android/fr-FR/changelogs/10350.txt ================================================ Mise à jour de la fonctionnalité : Afficher la boîte de dialogue de téléchargement immédiatement après le partage de l'URL Empêcher le téléchargement avec les réseaux à compteur Correction de bogue : Comportement inattendu lors de l'ouverture de fichiers supprimés La rotation de l'appareil fait réapparaître les boîtes de dialogue ================================================ FILE: fastlane/metadata/android/fr-FR/full_description.txt ================================================ Caractéristiques Téléchargez des vidéos et des fichiers audio à partir de plateformes vidéo prises en charge par yt-dlp Incorporation de métadonnées et de vignettes vidéo dans les fichiers audio extraits Téléchargez toutes les vidéos de la liste de lecture en un seul clic Utilisez l'aria2c intégré comme téléchargeur externe pour tous vos téléchargements. Incorporation de sous-titres dans les vidéos téléchargées Exécutez des commandes yt-dlp personnalisées à l'aide de modèles. Gérez les téléchargements dans l'application et les modèles de commande personnalisés. Facile à utiliser et convivial Interface utilisateur de style Material Design 3, avec thème de couleurs dynamique ================================================ FILE: fastlane/metadata/android/fr-FR/short_description.txt ================================================ Téléchargeur vidéo/audio conçu et thématisé avec Material You ================================================ FILE: fastlane/metadata/android/fr-FR/title.txt ================================================ Seal ================================================ FILE: fastlane/metadata/android/hi/full_description.txt ================================================ विशेषताएं yt-dlp . द्वारा समर्थित वीडियो प्लेटफ़ॉर्म से वीडियो और ऑडियो फ़ाइलें डाउनलोड करें निकाली गई ऑडियो फ़ाइलों में मेटाडेटा और वीडियो थंबनेल एम्बेड करें प्लेलिस्ट में सभी वीडियो एक क्लिक से डाउनलोड करें अपने सभी डाउनलोड के लिए बाहरी डाउनलोडर के रूप में एम्बेडेड aria2c का उपयोग करें डाउनलोड किए गए वीडियो में उपशीर्षक एम्बेड करें टेम्प्लेट के साथ कस्टम yt-dlp कमांड निष्पादित करें इन-ऐप डाउनलोड और कस्टम कमांड टेम्प्लेट प्रबंधित करें प्रयोग करने में आसान और उपयोगकर्ता के अनुकूल सामग्री डिजाइन 3 शैली यूआई, गतिशील रंग विषय के साथ ================================================ FILE: fastlane/metadata/android/hi/short_description.txt ================================================ वीडियो/ऑडियो डाउनलोडर जिसे आप सामग्री के साथ डिजाइन और थीम पर आधारित करते हैं ================================================ FILE: fastlane/metadata/android/hi/title.txt ================================================ सील ================================================ FILE: fastlane/metadata/android/hr/changelogs/10330.txt ================================================ Nove Značajke: Privatni Način rada: Onemogućite minijature i povijest preuzimanja Aplikacija prevedena na bjeloruski jezik uz pomoć @Kekich-dev ================================================ FILE: fastlane/metadata/android/hr/changelogs/10340.txt ================================================ Nove Značajke: Mogućnost odabira dinamičnih boja za temu na Android 12+ verzijama Popravljena greška: Padajući prikaz se ne pojavljuje prilikom odabiranja razlučivosti videa Prijevod na hrvatski dodao/la: @ElizabethWega ================================================ FILE: fastlane/metadata/android/hr/full_description.txt ================================================ Značajke Preuzimajte video datoteke i zvučne zapise sa bilo koje video platforme koju podržava yt-dlp Ugrađeni meta-podaci i minijatura videa izvedeni u audio datoteke Sa samo jednim klikom preuzmite sve videe unutar popisa za reprodukciju Mogućnost korištenja aria2c kako biste preuzeli ono što želite Mogućnost ugrađivanja podnaslova u preuzete videe Izvršite razne yt-dlp naredbe pomoću naredbenih predložaka Upravljajte svojim preuzimanjima i prilagođenim naredbenim predlošcima Lako za korištenje i prilagođeno korisniku Material Design 3 stil korisničkog sučelja, s dinamičnom promjenom boje tema ================================================ FILE: fastlane/metadata/android/hr/short_description.txt ================================================ Preuzima video/audio datoteka, dizajniran pomoću Material You ================================================ FILE: fastlane/metadata/android/hr/title.txt ================================================ Seal ================================================ FILE: fastlane/metadata/android/id/full_description.txt ================================================ Fitur Unduh video dan file audio dari platform video yang didukung oleh yt-dlp Sematkan metadata dan gambar mini video ke dalam file audio yang diekstrak Unduh semua video di daftar putar dengan satu klik Gunakan aria2c yang disematkan sebagai pengunduh eksternal untuk semua unduhan Anda Sematkan subtitle ke dalam video yang diunduh Jalankan perintah yt-dlp khusus dengan template Kelola unduhan dalam aplikasi dan templat perintah khusus Mudah digunakan dan ramah pengguna Desain Material 3 gaya UI, dengan tema warna dinamis ================================================ FILE: fastlane/metadata/android/id/short_description.txt ================================================ Pengunduh video/audio didesain dan ditema dengan Material You ================================================ FILE: fastlane/metadata/android/id/title.txt ================================================ Seal ================================================ FILE: fastlane/metadata/android/it/full_description.txt ================================================ Features Download video e file audio da piattaforme video supportate da yt-dlp Incorpora metadati e miniature video nei file audio estratti Scarica tutti i video nella playlist con un clic Usa aria2c incorporato come downloader esterno per tutti i tuoi download Incorpora i sottotitoli nei video scaricati Esegui comandi yt-dlp personalizzati con templates Gestisci i download in-app e templates di comando personalizzati Facile da usare e user-friendly UI in stile Material Design 3, con tema colori dinamico ================================================ FILE: fastlane/metadata/android/it/short_description.txt ================================================ Video/Audio downloader progettato a tema con Material You ================================================ FILE: fastlane/metadata/android/it/title.txt ================================================ Seal ================================================ FILE: fastlane/metadata/android/ja/full_description.txt ================================================ 機能 yt-dlp がサポートするビデオプラットフォームから動画や音声ファイルをダウンロードすることができます。 抽出した音声ファイルへのメタデータと動画サムネイルの埋め込み ワンクリックでプレイリスト内の全動画をダウンロード 外部ダウンローダーとして埋め込まれたaria2cを使用し、すべてのダウンロードを行うことができます。 ダウンロードしたビデオへの字幕の埋め込み テンプレートによるyt-dlpのカスタムコマンドの実行 アプリ内ダウンロードとカスタムコマンドテンプレートの管理 使いやすく、ユーザーフレンドリー システムのカラーテーマに対応したMaterial Design 3スタイルのUI ================================================ FILE: fastlane/metadata/android/ja/short_description.txt ================================================ Material You デザインの動画・音声ダウンローダー ================================================ FILE: fastlane/metadata/android/ja/title.txt ================================================ Seal ================================================ FILE: fastlane/metadata/android/ml/full_description.txt ================================================ സവിശേഷതകൾ yt-dlp പിന്തുണയ്ക്കുന്ന വീഡിയോ പ്ലാറ്റ്‌ഫോമുകളിൽ നിന്ന് വീഡിയോകളും ഓഡിയോ ഫയലുകളും ഡൗൺലോഡ് ചെയ്യുക എക്‌സ്‌ട്രാക്‌റ്റുചെയ്‌ത ഓഡിയോ ഫയലുകളിലേക്ക് മെറ്റാഡാറ്റയും വീഡിയോ ലഘുചിത്രവും ഉൾച്ചേർക്കുക ഒരു ക്ലിക്കിലൂടെ പ്ലേലിസ്റ്റിലെ എല്ലാ വീഡിയോകളും ഡൗൺലോഡ് ചെയ്യുക നിങ്ങളുടെ എല്ലാ ഡൗൺലോഡുകൾക്കും ബാഹ്യ ഡൌൺലോഡറായി ഉൾച്ചേർത്ത aria2c ഉപയോഗിക്കുക ഡൗൺലോഡ് ചെയ്‌ത വീഡിയോകളിൽ സബ്‌ടൈറ്റിലുകൾ ഉൾച്ചേർക്കുക ടെംപ്ലേറ്റുകൾ ഉപയോഗിച്ച് ഇഷ്‌ടാനുസൃത yt-dlp കമാൻഡുകൾ എക്‌സിക്യൂട്ട് ചെയ്യുക ഇൻ-ആപ്പ് ഡൗൺലോഡുകളും ഇഷ്‌ടാനുസൃത കമാൻഡ് ടെംപ്ലേറ്റുകളും നിയന്ത്രിക്കുക ഉപയോഗിക്കാൻ എളുപ്പവും ഉപയോക്തൃ സൗഹൃദവും ഡൈനാമിക് കളർ തീം ഉള്ള മെറ്റീരിയൽ ഡിസൈൻ 3 ശൈലി UI ================================================ FILE: fastlane/metadata/android/ml/short_description.txt ================================================ വീഡിയോ/ഓഡിയോ ഡൗൺലോഡർ രൂപകൽപ്പന ചെയ്‌ത് മെറ്റീരിയൽ യൂ ഉപയോഗിച്ച് തീം ചെയ്യുന്നു ================================================ FILE: fastlane/metadata/android/ml/title.txt ================================================ സീൽ ================================================ FILE: fastlane/metadata/android/nb-NO/full_description.txt ================================================ Funksjoner Last ned video- og lydfiler fra videoplattformer som støttes av yt-dlp Innebygg metadata og videominiatyrbilder inn i utledede lydfiler Last ned all video i en spilleliste med ett klikk Bruk innebygd aria2c som ekstern nedlaster for alle nedlastinger Innebygg undertekster inn i alle nedlastede videoer Kjør egendefinerte kommandoer og maler i yt-dlp Håndter nedlastinger og egendefinerte kommandomaler i programmet Enkelt å bruke og brukervennlig Materiell design 3 , med dynamisk fargedrakt ================================================ FILE: fastlane/metadata/android/nb-NO/short_description.txt ================================================ Nedlaster for video og lyd i materiell stil ================================================ FILE: fastlane/metadata/android/nb-NO/title.txt ================================================ Seal ================================================ FILE: fastlane/metadata/android/nl-NL/changelogs/10350.txt ================================================ Feature Update: Toon download dialoog direct na het delen van url's Voorkomen van downloaden met netwerken met een meter Bug Fix: Onverwacht gedrag bij het openen van verwijderde bestanden Draaien van apparaat zorgt ervoor dat dialogen weer verschijnen ================================================ FILE: fastlane/metadata/android/nl-NL/full_description.txt ================================================ Kenmerken Download video's en audiobestanden van videoplatforms die door yt-dlp worden ondersteund Embed metadata en video thumbnail in uitgepakte audiobestanden Download alle video's in de afspeellijst met één klik Gebruik embedded aria2c als externe downloader voor al uw downloads Ondertitels insluiten in gedownloade video's Voer aangepaste yt-dlp commando's uit met sjablonen Beheer in-app downloads en aangepaste opdrachtsjablonen Gebruiksvriendelijk en gebruiksvriendelijk Material Design 3 stijl UI, met dynamisch kleurenthema ================================================ FILE: fastlane/metadata/android/nl-NL/short_description.txt ================================================ Video/Audio downloader ontworpen en gethematiseerd met Material You ================================================ FILE: fastlane/metadata/android/nl-NL/title.txt ================================================ Zeehond ================================================ FILE: fastlane/metadata/android/pt-BR/short_description.txt ================================================ Baixador de Vídeo/Audio projetado e tematizado com Material You ================================================ FILE: fastlane/metadata/android/pt-BR/title.txt ================================================ Seal ================================================ FILE: fastlane/metadata/android/ru/full_description.txt ================================================ Особенности Загружайте видео- и аудиофайлы с видеоплатформ, поддерживаемых yt-dlp Встраивать метаданные и миниатюры видео в извлеченные аудиофайлы Загружайте все из плейлиста одним нажатием Используйте встроенный aria2c в качестве внешнего загрузчика для всех Ваших загрузок Добавляйте субтитры к загруженным видео Выполнение пользовательских команд yt-dlp с помощью шаблонов Управление загрузками в приложении и пользовательскими шаблонами команд Простой в использовании и удобный для пользователя Пользовательский интерфейс в стиле Material Design 3 с динамической цветовой темой ================================================ FILE: fastlane/metadata/android/ru/short_description.txt ================================================ Видео/аудио загрузчик разработанный и созданный с помощью Material You ================================================ FILE: fastlane/metadata/android/ru/title.txt ================================================ Seal ================================================ FILE: fastlane/metadata/android/th/full_description.txt ================================================ ความสามารถ ดาวน์โหลดไฟล์วิดีโอและเสียงจากแพลตฟอร์มวิดีโอด้วย yt-dlp ฝังข้อมูลเมตาและภาพขนาดย่อของวิดีโอลงในไฟล์เสียงที่แยกออกมา ดาวน์โหลดวิดีโอทั้งหมดในเพลย์ลิสต์ได้ในคลิกเดียว ใช้ aria2c แบบฝังเป็นตัวดาวน์โหลดภายนอกสำหรับการดาวน์โหลดทั้งหมด ฝังคำบรรยายลงในวิดีโอที่ดาวน์โหลดไว้ ใช้คำสั่ง yt-dlp แบบกำหนดเองด้วยเทมเพลต จัดการการดาวน์โหลดภายในแอพและเทมเพลตคำสั่งกำหนดเอง ใช้งานง่ายและเป็นมิตรกับผู้ใช้ UI สไตล์ Material Design 3 ด้วยธีมสีไดนามิก ================================================ FILE: fastlane/metadata/android/th/short_description.txt ================================================ โปรแกรมดาวน์โหลดวิดีโอ/เสียงที่ออกแบบในธีมbMaterial You ================================================ FILE: fastlane/metadata/android/th/title.txt ================================================ Seal ================================================ FILE: fastlane/metadata/android/uk/full_description.txt ================================================ Особливості Завантажуйте відео та аудіо файли з відео-платформ, які підтримуються yt-dlp Вбудовуйте дані і прев'ю у вилучені аудіо файли Завантажуйте усі відео з плейлиста в один клік Використовуйте вбудований aria2c як зовнішній завантажувач для всіх своїх завантажень Вбудовуйте субтитри у завантажені відео Виконуйте власні команди yt-dlp із шаблонами Керуйте завантаження у застосунку та спеціальні шаблони команд Простий у використанні та зручний Стилізований у Material Design 3 інтерфейс з динамічною колірною схемою ================================================ FILE: fastlane/metadata/android/uk/short_description.txt ================================================ Завантажувач відео та аудіо розроблений з Material You ================================================ FILE: fastlane/metadata/android/uk/title.txt ================================================ Тюлень ================================================ FILE: fastlane/metadata/android/vi/changelogs/10320.txt ================================================ Cải tiến khả năng tiếp cận: Loại bỏ ngữ nghĩa thừa và tối ưu hóa các thành phần cho trình đọc màn hình TalkBack Vá lỗi: Ứng dụng bị treo do biểu tượng trên thanh trạng thái Tùy chọn định dạng MP4 đôi khi không hoạt động như mong đợi ================================================ FILE: fastlane/metadata/android/vi/full_description.txt ================================================ Tính năng Tải video và âm thanh từ các nền tảng được hỗ trợ bởi yt-dlp Nhúng metadata và thumbnail video vào tệp âm thanh Tải tất cả video từ danh sách phát chỉ với một nhấn Sử dụng aria2c làm trình tải về bên ngoài cho tất cả các Tải xuống của bạn Nhúng chú thích vào video đã tải Thực thi các lệnh yt-dlp tùy chỉnh theo mẫu Quản lý các tải xuống trong ứng dụng và mẫu lệnh tùy chỉnh Dễ sử dụng và thân thiện với người dùng Thiết kế Material Design 3, cùng với chủ đề màu động ================================================ FILE: fastlane/metadata/android/vi/short_description.txt ================================================ Phần mềm tải video/âm thanh và được thiết kế với Material You ================================================ FILE: fastlane/metadata/android/vi/title.txt ================================================ Hải Cẩu ================================================ FILE: fastlane/metadata/android/zh-CN/full_description.txt ================================================ 功能特色 从 yt-dlp 支持的视频平台下载视频和音频文件 将元数据和视频缩略图嵌入到提取的音频文件中 播放列表下载支持 使用 aria2c 进行下载 使用模板执行自定义 yt-dlp 命令 管理应用内下载与自定义命令模板 易于使用且用户友好 遵循 Material Design 3 设计规范,动态色彩应用界面 ================================================ FILE: fastlane/metadata/android/zh-CN/short_description.txt ================================================ 从数千个网站下载视频与音频,以 Material You 设计风格呈现。 ================================================ FILE: fastlane/metadata/android/zh-CN/title.txt ================================================ Seal ================================================ FILE: fastlane/metadata/android/zh-TW/changelogs/10330.txt ================================================ 功能更新: 無痕模式: 停用縮圖和儲存下載記錄 新增白俄羅斯文,由 @Kekich-dev 提供 ================================================ FILE: fastlane/metadata/android/zh-TW/full_description.txt ================================================ 功能特色 從 yt-dlp 支援的視訊平台下載視訊和音訊檔案 可嵌入中繼資料和視訊縮圖至擷取的音訊檔案 一鍵下載播放清單中的全數視訊 可使用內建之 aria2c 作為外部下載程式 可嵌入字幕至欲下載的視訊中 可使用自訂範本以執行自訂 yt-dlp 命令 輕鬆管理應用程式內下載和自訂範本 簡單易用、平易近人 以具有動態顏色主題的 Material Design 3 風格,作為介面 UI ================================================ FILE: fastlane/metadata/android/zh-TW/short_description.txt ================================================ 以 Material You 設計打造的視訊/音訊下載程式 ================================================ FILE: fastlane/metadata/android/zh-TW/title.txt ================================================ Seal ================================================ FILE: gradle/libs.versions.toml ================================================ [versions] accompanist = "0.34.0" androidGradlePlugin = "8.7.2" androidxComposeBom = "2025.03.01" androidxCore = "1.15.0" androidMaterial = "1.12.0" androidxAppCompat = "1.7.0" androidxActivity = "1.10.1" graphics = "1.0.1" constraintLayout = "1.1.0" androidxLifecycle = "2.8.7" androidxNavigation = "2.8.9" androidxEspresso = "3.5.0" androidxTestExt = "1.1.4" coil = "2.5.0" junit4 = "4.13.2" kotlin = "2.0.20" coroutines = "1.9.0" datetime = "0.6.1" serialization = "1.7.2" okhttp = "5.0.0-alpha.10" room = "2.6.1" ksp = "2.0.20-1.0.25" youtubedlAndroid = "0.17.3" mmkv = "1.3.12" # pin to v1.3.x for 32-bit support koin = "4.0.0" ktfmt = "0.20.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" } android-material = { group = "com.google.android.material", name = "material", version.ref = "androidMaterial" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppCompat" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } androidx-lifecycle-runtimeCompose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidxLifecycle" } accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" } accompanist-webview = { group = "com.google.accompanist", name = "accompanist-webview", version.ref = "accompanist" } accompanist-pager-indicators = { group = "com.google.accompanist", name = "accompanist-pager-indicators", version.ref = "accompanist" } #androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom-alpha", version.ref = "androidxComposeBom" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-compose-material-iconsExtended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-compose-animation = { group = "androidx.compose.animation", name = "animation" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-material = { group = "androidx.compose.material", name = "material" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-material3-windowSizeClass = { group = "androidx.compose.material3", name = "material3-window-size-class" } androidx-graphics-shapes = { group = "androidx.graphics", name = "graphics-shapes", version.ref = "graphics" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } androidx-compose-ui-test = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-constraintlayout-compose = { group = "androidx.constraintlayout", name = "constraintlayout-compose", version.ref = "constraintLayout" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigation" } androidx-test-ext = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidxTestExt" } androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidxEspresso" } coil-kt-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } junit4 = { group = "junit", name = "junit", version.ref = "junit4" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "datetime" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" } koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" } room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } #youtubedl-android-library = { group = "com.github.yausername.youtubedl_android", name = "library", version.ref = "youtubedlAndroid" } #youtubedl-android-ffmpeg = { group = "com.github.yausername.youtubedl_android", name = "ffmpeg", version.ref = "youtubedlAndroid" } #youtubedl-android-aria2c = { group = "com.github.yausername.youtubedl_android", name = "aria2c", version.ref = "youtubedlAndroid" } youtubedl-android-library = { group = "io.github.junkfood02.youtubedl-android", name = "library", version.ref = "youtubedlAndroid" } youtubedl-android-ffmpeg = { group = "io.github.junkfood02.youtubedl-android", name = "ffmpeg", version.ref = "youtubedlAndroid" } youtubedl-android-aria2c = { group = "io.github.junkfood02.youtubedl-android", name = "aria2c", version.ref = "youtubedlAndroid" } mmkv = { group = "com.tencent", name = "mmkv", version.ref = "mmkv" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } android-test = { id = "com.android.test", version.ref = "androidGradlePlugin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } room = { id = "androidx.room", version.ref = "room" } ktfmt-gradle = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt" } [bundles] accompanist = [ "accompanist-permissions", "accompanist-webview", "accompanist-pager-indicators", ] androidxCompose = ["androidx-compose-ui", "androidx-compose-ui-tooling-preview", "androidx-compose-material-iconsExtended", "androidx-compose-material3", "androidx-compose-material3-windowSizeClass", "androidx-compose-foundation", "androidx-navigation-compose", "androidx-compose-animation", "androidx-constraintlayout-compose" ] youtubedlAndroid = ["youtubedl-android-library", "youtubedl-android-ffmpeg", "youtubedl-android-aria2c"] core = ["androidx-activity-compose", "android-material", "androidx-appcompat", "androidx-core-ktx"] ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ #Mon Oct 23 22:26:59 CST 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ # Project-wide Gradle settings. # IDE (e.g. Android Studio) users: # Gradle settings configured through the IDE *will override* # any settings specified in this file. # For more details on how to configure your build environment visit # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app"s APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true ABI_FILTERS=arm64-v8a android.nonFinalResIds=true org.gradle.configuration-cache=true ================================================ FILE: gradlew ================================================ #!/usr/bin/env sh # # Copyright 2015 the original author or authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ############################################################################## ## ## Gradle start up script for UN*X ## ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link PRG="$0" # Need this for relative symlinks. while [ -h "$PRG" ] ; do ls=`ls -ld "$PRG"` link=`expr "$ls" : '.*-> \(.*\)$'` if expr "$link" : '/.*' > /dev/null; then PRG="$link" else PRG=`dirname "$PRG"`"/$link" fi done SAVED="`pwd`" cd "`dirname \"$PRG\"`/" >/dev/null APP_HOME="`pwd -P`" cd "$SAVED" >/dev/null APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" warn () { echo "$*" } die () { echo echo "$*" echo exit 1 } # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "`uname`" in CYGWIN* ) cygwin=true ;; Darwin* ) darwin=true ;; MINGW* ) msys=true ;; NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD="$JAVA_HOME/jre/sh/java" else JAVACMD="$JAVA_HOME/bin/java" fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD="java" which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi # Increase the maximum file descriptors if we can. if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then MAX_FD_LIMIT=`ulimit -H -n` if [ $? -eq 0 ] ; then if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then MAX_FD="$MAX_FD_LIMIT" fi ulimit -n $MAX_FD if [ $? -ne 0 ] ; then warn "Could not set maximum file descriptor limit: $MAX_FD" fi else warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" fi fi # For Darwin, add options to specify how the application appears in the dock if $darwin; then GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" fi # For Cygwin or MSYS, switch paths to Windows format before running java if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then APP_HOME=`cygpath --path --mixed "$APP_HOME"` CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` JAVACMD=`cygpath --unix "$JAVACMD"` # We build the pattern for arguments to be converted via cygpath ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` SEP="" for dir in $ROOTDIRSRAW ; do ROOTDIRS="$ROOTDIRS$SEP$dir" SEP="|" done OURCYGPATTERN="(^($ROOTDIRS))" # Add a user-defined pattern to the cygpath arguments if [ "$GRADLE_CYGPATTERN" != "" ] ; then OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" fi # Now convert the arguments - kludge to limit ourselves to /bin/sh i=0 for arg in "$@" ; do CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` else eval `echo args$i`="\"$arg\"" fi i=`expr $i + 1` done case $i in 0) set -- ;; 1) set -- "$args0" ;; 2) set -- "$args0" "$args1" ;; 3) set -- "$args0" "$args1" "$args2" ;; 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; esac fi # Escape application args save () { for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done echo " " } APP_ARGS=`save "$@"` # Collect all arguments for the java command, following the shell quoting and substitution rules eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if "%ERRORLEVEL%" == "0" goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell if "%ERRORLEVEL%"=="0" goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 exit /b 1 :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: settings.gradle.kts ================================================ pluginManagement { repositories { gradlePluginPortal() google() mavenCentral() } } plugins { id("org.gradle.toolchains.foojay-resolver-convention") version("0.4.0") } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() mavenLocal() } } rootProject.name = "Seal" include (":app") include(":color") ================================================ FILE: translations/README-ar.md ================================================
# Seal ### تطبيق لتحميل ملفات الفيديو والصوت من الإنترنت لنظام تشغيل اندرويد English   |    العربية [![F-Droid](https://img.shields.io/f-droid/v/com.junkfood.seal?color=b4eb12&label=F-Droid&logo=fdroid&logoColor=1f78d2)](https://f-droid.org/en/packages/com.junkfood.seal) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/JunkFood02/Seal?color=black&label=Stable&logo=github)](https://github.com/JunkFood02/Seal/releases/latest/) [![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/JunkFood02/Seal?include_prereleases&label=Preview&logo=Github)](https://github.com/JunkFood02/Seal/releases/) [![Keep a Changelog](https://img.shields.io/badge/Changelog-lightgray?style=flat&color=gray&logo=keep-a-changelog)](https://github.com/JunkFood02/Seal/blob/main/CHANGELOG.md) [![GitHub all releases](https://img.shields.io/github/downloads/JunkFood02/Seal/total?label=Downloads&logo=github)](https://github.com/JunkFood02/Seal/releases/) [![GitHub Repo stars](https://img.shields.io/github/stars/JunkFood02/Seal?color=informational&label=Stars)](https://github.com/JunkFood02/Seal/stargazers) [![Supported Sites](https://img.shields.io/badge/Supported-Sites-9cf.svg?style=flat)](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) [![Telegram Channel](https://img.shields.io/badge/Telegram-Seal-blue?style=flat&logo=telegram)](https://t.me/seal_app) [![Matrix Space](https://img.shields.io/badge/Matrix-Seal-Black?style=flat&color=black&logo=matrix)](https://matrix.to/#/#seal-space:matrix.org)
## 📱 لقطات شاشة
## 📖 الميزات
  • تحميل مقاطع الفيديو والملفات الصوتية من منصات الفيديو المدعومة من yt-dlp سابقا youtube-dl.
  • قم بتضمين البيانات الوصفية وصورة الفيديو المصغرة في ملفات الصوت المستخرجة التي يدعمها mutagen.
  • استخدم aria2c المدمج كبرنامج تنزيل خارجي لجميع التنزيلات الخاصة بك.
  • دمج ملفات الترجمة في مقاطع الفيديو التي تم تنزيلها.
  • تنفيذ أوامر yt-dlp المخصصة باستخدام القوالب.
  • إدارة التنزيلات داخل التطبيق وقوالب الأوامر المخصصة.
  • سهل الاستخدام.
  • واجهة مستخدم بنمط تصميم Material Design 3 ، مع سمة ألوان ديناميكية Dynamic color.
  • واجهة المستخدم والمنطق مكتوبة باستخدام لغة Kotlin. نشاط فردي ، بدون أجزاء ، وجهات قابلة للتكوين فقط.
## ⬇️ تحميل بالنسبة لمعظم الأجهزة ، يوصى بتثبيت إصدار **arm64-va** من التطبيق قم بتنزيل أحدث إصدار مستقر من إصدارات GitHub قم بتثبيت الإصدارات المسبقة لمساعدتنا في اختبار الميزات والتغييرات الجديدة الإصدارات المستقرة متاحة أيضًا على F-Droid [Get it on F-Droid](https://f-droid.org/packages/com.junkfood.seal/) ## 🤝 المساهمة المساهمات مرحب بها ! يمكنك المساعدة في ترجمة Seal على موقع [Hosted Weblate](https://hosted.weblate.org/projects/seal/). ## 🔤 حالة الترجمة

حالة الترجمة لإرسال تقارير الأخطاء أو طلب ميزات جديدة أو الأسئلة أو أي أفكار أخرى للتحسين ، يرجى قراءة ملف [CONTRIBUTING.md](https://github.com/JunkFood02/Seal/blob/main/CONTRIBUTING.md) للحصول على الإرشادات أولاً. ## ⭐️ تاريخ النجوم

Star History Chart ## 🧱 الاعتمادات هذا البرنامج عبارة عن واجهة مستخدم بسيطة لـ [yt-dlp](https://github.com/yt-dlp/yt-dlp) ، المبني على [youtubedl-android](https://github.com/yausername/youtubedl-android) تم استعارة بعض تصميمات وبرمجيات واجهة المستخدم من [Read You](https://github.com/Ashinch/ReadYou) و [Music You](https://github.com/Kyant0/MusicYou)

dvd

Material color utilities

Monet

## 📃 الرُخصة

GitHub ​ ## ⚠️ تحذير

    باستثناء الرمز المصدري المرخص بموجب ترخيص GPLv3، يُحظر على جميع الأطراف الأخرى استخدام اسم Seal كتطبيق تنزيل، وينطبق الشيء نفسه على مشتقات\نسخ Seal. المشتقات على سبيل المثال ليست حَصْرًا النسخ (Forks) والبنيات غير الرسمية (Unofficial Builds).

================================================ FILE: translations/README-az.md ================================================
# Seal ### Android üçün Video/Səs Yükləyici

English   |    Azərbaycanca

[![F-Droid](https://img.shields.io/f-droid/v/com.junkfood.seal?color=b4eb12&label=F-Droid&logo=fdroid&logoColor=1f78d2)](https://f-droid.org/en/packages/com.junkfood.seal) [![GitHub buraxılışı (ən son tarixə görə)](https://img.shields.io/github/v/release/JunkFood02/Seal?color=black&label=Stable&logo=github)](https://github.com/JunkFood02/Seal/releases/latest/) [![GitHub buraxılışı (ilkin buraxılışlar daxil olmaqla son tarix)](https://img.shields.io/github/v/release/JunkFood02/Seal?include_prereleases&label=Preview&logo=Github)](https://github.com/JunkFood02/Seal/releases/) [![Dəyişikliklər jurnalın saxla](https://img.shields.io/badge/Changelog-lightgray?style=flat&color=gray&logo=keep-a-changelog)](https://github.com/JunkFood02/Seal/blob/main/CHANGELOG.md) [![GitHub bütün buraxılışlar](https://img.shields.io/github/downloads/JunkFood02/Seal/total?label=Downloads&logo=github)](https://github.com/JunkFood02/Seal/releases/) [![GitHub Depo ulduzları](https://img.shields.io/github/stars/JunkFood02/Seal?color=informational&label=Stars)](https://github.com/JunkFood02/Seal/stargazers) [![Dəstəklənən Saytlar](https://img.shields.io/badge/Supported-Sites-9cf.svg?style=flat)](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) [![Telegram Kanalı](https://img.shields.io/badge/Telegram-Seal-blue?style=flat&logo=telegram)](https://t.me/seal_app) [![Matrix Məkanı](https://img.shields.io/badge/Matrix-Seal-Black?style=flat&color=black&logo=matrix)](https://matrix.to/#/#seal-space:matrix.org)
## 📱 Ekran görüntüləri

## 📖 Xüsusiyyətləri - [yt-dlp](https://github.com/yt-dlp/yt-dlp)(əvvəllər youtube-dl) tərəfindən dəstəklənən video platformalardan video və səs faylları yüklə. - [mutagen](https://github.com/quodlibet/mutagen) tərəfindən dəstəklənən çıxarılan səs fayllarına üst məlumat və video miniatür yerləşdirin. - Pleylistdəki bütün videoları bir kliklə yüklə. - Bütün yükləmələriniz üçün xarici yükləyici kimi yerləşdirilmiş [aria2c](https://github.com/aria2/aria2) istifadə edin. - Yüklənilmiş videolara titrlər yerləşdir. - Şablonlarla şəxsi yt-dlp əmrlərin icra edin. - Tətbiqdaxili yükləmələri və şəxsi əmr şablonların idarə et. - İstifadə üçün asan və istifadəçi dostudur. - [Material Design 3](https://m3.material.io/) stil UI, dinamik rəng temalı. - MAD: UI və təmiz Kotlin ilə yazılan məntiq.Tək fəaliyyət, hissələr yoxdur, yalnız tərtib edilə bilən istiqamətlər. ## ⬇️ Yüklənilmə Əksər cihazlar üçün apk-lərin **arm64-v8a** versiyasın quraşdırmaq tövsiyə olunur - Ən son stabil versiyanı [GitHub buraxılışlarından](https://github.com/JunkFood02/Seal/releases/latest) yüklə - Yeni xüsusiyyətləri & dəyişiklikləri sınamağımıza kömək etmək üçün [buraxılışdan əvvəl](https://github.com/JunkFood02/Seal/releases/) versiyaların quraşdır - Həmçinin stabil buraxılışlar [F-Droid](https://f-droid.org/packages/com.junkfood.seal/)-də mövcuddur ## 💬 Əlaqə Müzakirə, elanlar və buraxılışlar üçün [Telegram Kanalımıza](https://t.me/seal_app) və ya [Matrix Məkanımıza](https://matrix.to/#/#seal-space:matrix.org) qoşulun! ## 💖 Himayədarlar

mohammed_9456Jas0n2kGordon

Seal həmişə, hər kəs üçün pulsuz və açıq mənbə olacaqdır.Əgər bunu bəyənirsinizsə, xahiş edirəm [mənə himayədarlıq etməyi](https://github.com/sponsors/JunkFood02) fikirləşin! ## 🤝 Töhfə Töhfələr Xoşdur! Siz [Hosted Weblate](https://hosted.weblate.org/projects/seal/)-də Seal-ı tərcümə etməyə kömək edə bilərsiniz. [![Tərcümə vəziyyəti](https://hosted.weblate.org/widgets/seal/-/strings/multi-auto.svg)](https://hosted.weblate.org/engage/seal/) >**Qeyd** > >Səhv hesabatları, xüsusiyyət sorğuları, suallar və ya təkmilləşdirmək üçün hər hansı digər ideyalar təqdim etmək üçün əvvəlcə təlimlər və təlimatlar üçün [CONTRIBUTING.md](https://github.com/JunkFood02/Seal/blob/main/CONTRIBUTING.md) bölməsin oxuyun. ## ⭐️ Ulduz Tarixçəsi [![Ulduz Tarixçəsi Sxemi](https://api.star-history.com/svg?repos=JunkFood02/Seal&type=Timeline)](https://star-history.com/#JunkFood02/Seal&Timeline) ## 🧱 Kreditlər Seal [youtubedl-android](https://github.com/yausername/youtubedl-android) əsasında [yt-dlp](https://github.com/yt-dlp/yt-dlp) ilə sadə GUI-dir. Bəzi UI dizaynları və kodları [Read You](https://github.com/Ashinch/ReadYou) və [Music You](https://github.com/Kyant0/MusicYou)-dan götürülmüşdür. [dvd](https://github.com/yausername/dvd) [Material color utilities](https://github.com/material-foundation/material-color-utilities) [Monet](https://github.com/Kyant0/Monet) ## 📃 Lisenziya [![GitHub](https://img.shields.io/github/license/JunkFood02/Seal?style=for-the-badge)](https://github.com/JunkFood02/Seal/blob/main/LICENSE) >**Xəbərdarlıq** > >GPLv3 lisenziyası əsasında lisenziyalaşdırılmış mənbə kodu istisna olmaqla, >bütün digər tərəflərə Seal-ın adından yükləyici tətbiq kimi istifadə etmək qadağandır, >və eynisi Seal-ın törəmələri üçün keçərlidir. >Törəmələrə çəngəllər və qeyri-rəsmi quruluşlar daxildir, lakin bunlarla məhdudlaşmır. ================================================ FILE: translations/README-bn.md ================================================
# Seal ### Video/Audio Downloader for Android English   |    简体中文   |    繁體中文   |    العربية   |    Portuguese   |    Українська   |    ภาษาไทย   |    فارسی   |    Italiano   |    Azərbaycanca   |    Русский   |    Српски   |    日本語   |    Indonesia   |    हिंदी   |    বাংলা [![F-Droid](https://img.shields.io/f-droid/v/com.junkfood.seal?color=b4eb12&label=F-Droid&logo=fdroid&logoColor=1f78d2)](https://f-droid.org/en/packages/com.junkfood.seal) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/JunkFood02/Seal?color=black&label=Stable&logo=github)](https://github.com/JunkFood02/Seal/releases/latest/) [![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/JunkFood02/Seal?include_prereleases&label=Preview&logo=Github)](https://github.com/JunkFood02/Seal/releases/) [![Keep a Changelog](https://img.shields.io/badge/Changelog-lightgray?style=flat&color=gray&logo=keep-a-changelog)](https://github.com/JunkFood02/Seal/blob/main/CHANGELOG.md) [![GitHub all releases](https://img.shields.io/github/downloads/JunkFood02/Seal/total?label=Downloads&logo=github)](https://github.com/JunkFood02/Seal/releases/) [![GitHub Repo stars](https://img.shields.io/github/stars/JunkFood02/Seal?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIC05NjAgOTYwIDk2MCIgd2lkdGg9IjI0IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxwYXRoIGQ9Im0zNTQtMjQ3IDEyNi03NiAxMjYgNzctMzMtMTQ0IDExMS05Ni0xNDYtMTMtNTgtMTM2LTU4IDEzNS0xNDYgMTMgMTExIDk3LTMzIDE0M1pNMjMzLTgwbDY1LTI4MUw4MC01NTBsMjg4LTI1IDExMi0yNjUgMTEyIDI2NSAyODggMjUtMjE4IDE4OSA2NSAyODEtMjQ3LTE0OUwyMzMtODBabTI0Ny0zNTBaIiBzdHlsZT0iZmlsbDogcmdiKDI0NSwgMjI3LCA2Nik7Ii8%2BCjwvc3ZnPg%3D%3D&color=%23f8e444)](https://github.com/JunkFood02/Seal/stargazers) [![Supported-Sites](https://img.shields.io/badge/Sites-9cf?style=flat&logo=data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyBoZWlnaHQ9IjI0cHgiIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0cHgiIGZpbGw9IiNGRkZGRkYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTAgMGgyNHYyNEgwVjB6IiBmaWxsPSJub25lIi8+CiAgPHBhdGggZD0iTTExLjk5IDJDNi40NyAyIDIgNi40OCAyIDEyczQuNDcgMTAgOS45OSAxMEMxNy41MiAyMiAyMiAxNy41MiAyMiAxMlMxNy41MiAyIDExLjk5IDJ6bTYuOTMgNmgtMi45NWMtLjMyLTEuMjUtLjc4LTIuNDUtMS4zOC0zLjU2IDEuODQuNjMgMy4zNyAxLjkxIDQuMzMgMy41NnpNMTIgNC4wNGMuODMgMS4yIDEuNDggMi41MyAxLjkxIDMuOTZoLTMuODJjLjQzLTEuNDMgMS4wOC0yLjc2IDEuOTEtMy45NnpNNC4yNiAxNEM0LjEgMTMuMzYgNCAxMi42OSA0IDEycy4xLTEuMzYuMjYtMmgzLjM4Yy0uMDguNjYtLjE0IDEuMzItLjE0IDJzLjA2IDEuMzQuMTQgMkg0LjI2em0uODIgMmgyLjk1Yy4zMiAxLjI1Ljc4IDIuNDUgMS4zOCAzLjU2LTEuODQtLjYzLTMuMzctMS45LTQuMzMtMy41NnptMi45NS04SDUuMDhjLjk2LTEuNjYgMi40OS0yLjkzIDQuMzMtMy41NkM4LjgxIDUuNTUgOC4zNSA2Ljc1IDguMDMgOHpNMTIgMTkuOTZjLS44My0xLjItMS40OC0yLjUzLTEuOTEtMy45NmgzLjgyYy0uNDMgMS40My0xLjA4IDIuNzYtMS45MSAzLjk2ek0xNC4zNCAxNEg5LjY2Yy0uMDktLjY2LS4xNi0xLjMyLS4xNi0ycy4wNy0xLjM1LjE2LTJoNC42OGMuMDkuNjUuMTYgMS4zMi4xNiAycy0uMDcgMS4zNC0uMTYgMnptLjI1IDUuNTZjLjYtMS4xMSAxLjA2LTIuMzEgMS4zOC0zLjU2aDIuOTVjLS45NiAxLjY1LTIuNDkgMi45My00LjMzIDMuNTZ6TTE2LjM2IDE0Yy4wOC0uNjYuMTQtMS4zMi4xNC0ycy0uMDYtMS4zNC0uMTQtMmgzLjM4Yy4xNi42NC4yNiAxLjMxLjI2IDJzLS4xIDEuMzYtLjI2IDJoLTMuMzh6IiBzdHlsZT0iZmlsbDogcmdiKDE2MiwgMTk4LCAyMzQpOyIvPgo8L3N2Zz4=&label=Supported)](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) [![Telegram Channel](https://img.shields.io/badge/Telegram-Seal-blue?style=flat&logo=telegram)](https://t.me/seal_app) [![Matrix](https://img.shields.io/matrix/seal-space%3Amatrix.org?server_fqdn=matrix.org&style=flat&logo=element&label=Matrix&color=%230DBD8B) ](https://matrix.to/#/#seal-space:matrix.org)
## 📱 স্ক্রিনশট

## 📖 ফিচারস - [yt-dlp](https://github.com/yt-dlp/yt-dlp) (পূর্বের youtube-dl) সাপোর্টেড ভিডিও প্ল্যাটফর্ম থেকে ভিডিও এবং অডিও ফাইল ডাউনলোড করুন। - [mutagen](https://github.com/quodlibet/mutagen) সাপোর্টেড এক্সট্র্যাক্টটেড অডিও ফাইলগুলিতে মেটাডেটা এবং ভিডিও থাম্বনেল এমবেডেড। - এক ক্লিকে প্লে লিস্টের সব ভিডিও ডাউনলোড করুন। - সব ডাউনলোডের জন্য ডাউনলোডার হিসাবে এমবেডেড [aria2c](https://github.com/aria2/aria2) . - ডাউনলোড করা ভিডিওগুলিতে সাবটাইটেল এম্বেড। - টেমপ্লেট সহ কাস্টম yt-dlp কমান্ড রান করা। - ইন-অ্যাপ ডাউনলোড এবং কাস্টম কমান্ড টেমপ্লেট এর ব্যাবহার। - ব্যবহার করা সহজ এবং ইউজার ফ্রেন্ডলি। - [Material Design 3](https://m3.material.io/) স্টাইলের UI, ডাইনামিক থিম সহ। - MAD: UI এবং logic pure Kotlin এ কোড করা. একক কার্যকলাপ, কোন টুকরা নেই, শুধুমাত্র কমপোজ যোগ্য গন্তব্য। ## ⬇️ ডাউনলোড বেশিরভাগ ডিভাইসের জন্য, apks-এর **arm64-v8a** ইনস্টল করুন। - [GitHub releases](https://github.com/JunkFood02/Seal/releases/latest) থেকে লেটেস্ট রিলিজ ডাউনলোড করুন। - আমাদের সাহায্য করতে নতুন ফিচার ও পরিবর্তন টেস্ট করতে জন্য [pre-release](https://github.com/JunkFood02/Seal/releases/) ভার্সন ইনস্টল করুন। - [F-Droid](https://f-droid.org/packages/com.junkfood.seal/) এও লেটেস্ট রিলিজ পাওয়া যাবে। ## 💬 যোগাযোগ আলোচনা, ঘোষণা এবং রিলিজের জন্য আমাদের [টেলিগ্রাম চ্যানেল](https://t.me/seal_app) বা [ম্যাট্রিক্স স্পেস](https://matrix.to/#/#seal-space:matrix.org) এ যোগ দিন! ## 💖 স্পনসর

GordonzubleDaniel

সীল সর্বদা বিনামূল্যে এবং সবার জন্য উন্মুক্ত থাকবে । আপনারা যদি এটা পছন্দ করে থাকেন, আমাকে [স্পনসর করুন!](https://github.com/sponsors/JunkFood02)! ## 🤝 অবদান সবার অবদানকে স্বাগত জানাই! আপনি [Hosted Weblate](https://hosted.weblate.org/projects/seal/) সিল অনুবাদ করতে সাহায্য করতে পারেন। [![Translate status](https://hosted.weblate.org/widgets/seal/-/strings/multi-auto.svg)](https://hosted.weblate.org/engage/seal/) >[!দ্রষ্টব্য] > >বাগ রিপোর্ট জমা দেওয়ার জন্য, ফিচার এর অনুরোধ, প্রশ্ন, বা ডেভেলপমেন্টের জন্য অন্য কোন পরামর্শ থাকলে অনুগ্রহ করে প্রথমে নির্দেশাবলী এবং নির্দেশিকাগুলির জন্য [CONTRIBUTING.md](https://github.com/JunkFood02/Seal/blob/main/CONTRIBUTING.md) পড়ুন। ## ⭐️ স্টার চার্ট [![Star History Chart](https://api.star-history.com/svg?repos=JunkFood02/Seal&type=Timeline)](https://star-history.com/#JunkFood02/Seal&Timeline) ## 🧱 ক্রেডিট [youtubedl-android](https://github.com/yausername/youtubedl-android) -এর উপর ভিত্তি করে সিল [yt-dlp](https://github.com/yt-dlp/yt-dlp) -এর একটি সাধারণ GUI, কিছু UI ডিজাইন এবং কোড [Read You](https://github.com/Ashinch/ReadYou) এবং [Music You](https://github.com/Kyant0/MusicYou) থেকে ধার করা হয়েছে। [dvd](https://github.com/yausername/dvd) [Material color utilities](https://github.com/material-foundation/material-color-utilities) [Monet](https://github.com/Kyant0/Monet) ## 📃 লাইসেন্স [![GitHub](https://img.shields.io/github/license/JunkFood02/Seal?style=for-the-badge)](https://github.com/JunkFood02/Seal/blob/main/LICENSE) >[!সতর্কতা] > >GPLv3 লাইসেন্সের অধীনে লাইসেন্সকৃত সোর্স কোড ব্যতীত, অন্যান্য সমস্ত পক্ষকে ডাউনলোডার অ্যাপ >হিসাবে সিলের নাম ব্যবহার করা নিষিদ্ধ এবং সিলের ডেরিভেটিভের ক্ষেত্রেও এটি সত্য। ডেরিভেটিভস >অন্তর্ভুক্ত কিন্তু ফোর্ক এবং অফিসিয়াল বিল্ডের মধ্যে সীমাবদ্ধ নয়। ================================================ FILE: translations/README-fa.md ================================================

Seal

[![F-Droid](https://img.shields.io/f-droid/v/com.junkfood.seal.svg?logo=F-Droid&color=green&style=flat-square)](https://f-droid.org/en/packages/com.junkfood.seal) [![Releases](https://img.shields.io/github/release/JunkFood02/Seal.svg?logo=github&color=171515&label=stable&style=flat-square)](https://github.com/JunkFood02/Seal/releases/latest) [![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/JunkFood02/Seal?include_prereleases&label=preview&logo=github)](https://github.com/JunkFood02/Seal/releases) [![GitHub all releases](https://img.shields.io/github/downloads/JunkFood02/Seal/total?style=flat-square)](https://github.com/JunkFood02/Seal/releases) [![GitHub Repo stars](https://img.shields.io/github/stars/JunkFood02/Seal?style=flat-square)](https://github.com/JunkFood02/Seal/stargazers) [![Supported Sites](https://img.shields.io/badge/Supported-Sites-9cf.svg?style=flat-square)](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) [![Telegram Channel](https://img.shields.io/badge/Telegram-Seal-blue?style=flat-square&logo=telegram)](https://t.me/seal_app) [![Matrix Space](https://img.shields.io/badge/Matrix-Seal-Black?style=flat-square&color=black&logo=matrix)](https://matrix.to/#/#seal-space:matrix.org)

English   |    فارسی

## 📱 اسکرین شات ها

## 📖 امکانات
  • دانلود ویدیو و صدا های پشتیبانی شده توسط yt-dlp
  • چسباندن فراداده و تصویر بندانگشتی ویدیو را در فایل های صوتی که توسط mutagen. پشتیبانی می شود
  • دانلود تمامی ویدیو های یک لیست پخش فقط با یک کلیک
  • قابلیت استفاده از aria2c برای دانلود
  • چسباندن زیرنویس ها به فیلم های دانلودی
  • اجرای دستورات سفارشی yt-dlp به وسیله الگو ها
  • امکان مدیریت دانلود های درون برنامه ای و الگو های سفارشی دستورات
  • سادگی در استفاده و کاربرپسند
  • طراحی رابط کاربری Material Design 3 با رنگ پویا
  • ظاهر برنامه و شرط ها با زبان کاتلین نوشته شده است
## ⬇️ دانلود برای اکثر دستگاه ها پیشنهاد می شود نسخه **arm64-v8a** را دانلود کنید. -دانلود آخرین نسخه برنامه [GitHub releases](https://github.com/JunkFood02/Seal/releases/latest) -[نسخه آزمایشی](https://github.com/JunkFood02/Seal/releases/) را جهت تست و کمک به ما در توسعه دانلود کنید - دانلود آخرین نسخه از F-Droid : [Get it on F-Droid](https://f-droid.org/packages/com.junkfood.seal/) ## 💬 تماس با ما جهت گفتگو و با خبر شدن از آخرین بروزرسانی به کانال ما در تلگرام به [@seal_app](https://t.me/seal_app) و در فضای ماتریکس به آدرس [Matrix Space](https://matrix.to/#/#seal-space:matrix.org) بپیوندید ## 🤝 همکاری علاقه مند به کمک به ما هستید ؟ برای کمک در ترجمه برنامه می توانید به [Hosted Weblate](https://hosted.weblate.org/projects/seal/) مراجعه کنید و مارا در ترجمه برنامه یاری کنید

وضعیت ترجمه >**نکته** جهت ارسال هرگونه گزارش مشکل, سوالات, ایده ها و کمک در توسعه برنامه ابتدا دستورالعمل را به آدرس [CONTRIBUTING.md](https://github.com/JunkFood02/Seal/blob/main/CONTRIBUTING.md) مطالعه کنید ## ⭐️تاریخچه امتیاز

Star History Chart

## 🧱 منابع برنامه Seal یک نسخه گرافیکی از [yt-dlp](https://github.com/yt-dlp/yt-dlp) می باشد و بر پایه [youtubedl-android](https://github.com/yausername/youtubedl-android) توسعه داده شده است برخی از کد های برنامه از [Read You](https://github.com/Ashinch/ReadYou) و [Music You](https://github.com/Kyant0/MusicYou) قرض گرفته شده است

dvd

Material color utilities

Monet

## 📃 لایسنس [![GitHub](https://img.shields.io/github/license/JunkFood02/Seal?style=for-the-badge)](https://github.com/JunkFood02/Seal/blob/main/LICENSE) ================================================ FILE: translations/README-hi.md ================================================
# Seal ### Android के लिए वीडियो/ऑडियो डाउनलोडर English   |    简体中文   |    繁體中文   |    العربية   |    Portuguese   |    Українська   |    ภาษาไทย   |    فارسی   |    Italiano   |    Azərbaycanca   |    Русский   |    Српски   |    日本語   |    Indonesia   |    हिंदी [![F-Droid](https://img.shields.io/f-droid/v/com.junkfood.seal?color=b4eb12&label=F-Droid&logo=fdroid&logoColor=1f78d2)](https://f-droid.org/en/packages/com.junkfood.seal) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/JunkFood02/Seal?color=black&label=Stable&logo=github)](https://github.com/JunkFood02/Seal/releases/latest/) [![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/JunkFood02/Seal?include_prereleases&label=Preview&logo=Github)](https://github.com/JunkFood02/Seal/releases/) [![Keep a Changelog](https://img.shields.io/badge/Changelog-lightgray?style=flat&color=gray&logo=keep-a-changelog)](https://github.com/JunkFood02/Seal/blob/main/CHANGELOG.md) [![GitHub all releases](https://img.shields.io/github/downloads/JunkFood02/Seal/total?label=Downloads&logo=github)](https://github.com/JunkFood02/Seal/releases/) [![GitHub Repo stars](https://img.shields.io/github/stars/JunkFood02/Seal?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIC05NjAgOTYwIDk2MCIgd2lkdGg9IjI0IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxwYXRoIGQ9Im0zNTQtMjQ3IDEyNi03NiAxMjYgNzctMzMtMTQ0IDExMS05Ni0xNDYtMTMtNTgtMTM2LTU4IDEzNS0xNDYgMTMgMTExIDk3LTMzIDE0M1pNMjMzLTgwbDY1LTI4MUw4MC01NTBsMjg4LTI1IDExMi0yNjUgMTEyIDI2NSAyODggMjUtMjE4IDE4OSA2NSAyODEtMjQ3LTE0OUwyMzMtODBabTI0Ny0zNTBaIiBzdHlsZT0iZmlsbDogcmdiKDI0NSwgMjI3LCA2Nik7Ii8%2BCjwvc3ZnPg%3D%3D&color=%23f8e444)](https://github.com/JunkFood02/Seal/stargazers) [![Supported-Sites](https://img.shields.io/badge/Sites-9cf?style=flat&logo=data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyBoZWlnaHQ9IjI0cHgiIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0cHgiIGZpbGw9IiNGRkZGRkYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTAgMGgyNHYyNEgwVjB6IiBmaWxsPSJub25lIi8+CiAgPHBhdGggZD0iTTExLjk5IDJDNi40NyAyIDIgNi40OCAyIDEyczQuNDcgMTAgOS45OSAxMEMxNy41MiAyMiAyMiAxNy41MiAyMiAxMlMxNy41MiAyIDExLjk5IDJ6bTYuOTMgNmgtMi45NWMtLjMyLTEuMjUtLjc4LTIuNDUtMS4zOC0zLjU2IDEuODQuNjMgMy4zNyAxLjkxIDQuMzMgMy41NnpNMTIgNC4wNGMuODMgMS4yIDEuNDggMi41MyAxLjkxIDMuOTZoLTMuODJjLjQzLTEuNDMgMS4wOC0yLjc2IDEuOTEtMy45NnpNNC4yNiAxNEM0LjEgMTMuMzYgNCAxMi42OSA0IDEycy4xLTEuMzYuMjYtMmgzLjM4Yy0uMDguNjYtLjE0IDEuMzItLjE0IDJzLjA2IDEuMzQuMTQgMkg0LjI2em0uODIgMmgyLjk1Yy4zMiAxLjI1Ljc4IDIuNDUgMS4zOCAzLjU2LTEuODQtLjYzLTMuMzctMS45LTQuMzMtMy41NnptMi45NS04SDUuMDhjLjk2LTEuNjYgMi40OS0yLjkzIDQuMzMtMy41NkM4LjgxIDUuNTUgOC4zNSA2Ljc1IDguMDMgOHpNMTIgMTkuOTZjLS44My0xLjItMS40OC0yLjUzLTEuOTEtMy45NmgzLjgyYy0uNDMgMS40My0xLjA4IDIuNzYtMS45MSAzLjk2ek0xNC4zNCAxNEg5LjY2Yy0uMDktLjY2LS4xNi0xLjMyLS4xNi0ycy4wNy0xLjM1LjE2LTJoNC42OGMuMDkuNjUuMTYgMS4zMi4xNiAycy0uMDcgMS4zNC0uMTYgMnptLjI1IDUuNTZjLjYtMS4xMSAxLjA2LTIuMzEgMS4zOC0zLjU2aDIuOTVjLS45NiAxLjY1LTIuNDkgMi45My00LjMzIDMuNTZ6TTE2LjM2IDE0Yy4wOC0uNjYuMTQtMS4zMi4xNC0ycy0uMDYtMS4zNC0uMTQtMmgzLjM4Yy4xNi42NC4yNiAxLjMxLjI2IDJzLS4xIDEuMzYtLjI2IDJoLTMuMzh6IiBzdHlsZT0iZmlsbDogcmdiKDE2MiwgMTk4LCAyMzQpOyIvPgo8L3N2Zz4=&label=Supported)](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) [![Telegram Channel](https://img.shields.io/badge/Telegram-Seal-blue?style=flat&logo=telegram)](https://t.me/seal_app) [![Matrix](https://img.shields.io/matrix/seal-space%3Amatrix.org?server_fqdn=matrix.org&style=flat&logo=element&label=Matrix&color=%230DBD8B) ](https://matrix.to/#/#seal-space:matrix.org)
## 📱 स्क्रीनशॉट

## 📖 विशेषताएँ - [yt-dlp](https://github.com/yt-dlp/yt-dlp) (पूर्व में youtube-dl) द्वारा समर्थित वीडियो प्लेटफ़ॉर्म से वीडियो और ऑडियो फ़ाइलें डाउनलोड करें। - [mutagen](https://github.com/quodlibet/mutagen) द्वारा समर्थित ऑडियो फ़ाइलों में मेटाडेटा और वीडियो थंबनेल एम्बेड करें। - एक क्लिक में प्लेलिस्ट में सभी वीडियो डाउनलोड करें। - सभी डाउनलोड के लिए बाहरी डाउनलोडर के रूप में एम्बेडेड [aria2c](https://github.com/aria2/aria2) का उपयोग करें। - डाउनलोड किए गए वीडियो में सबटाइटल एम्बेड करें। - टेम्प्लेट के साथ कस्टम yt-dlp कमांड चलाएँ। - इन-ऐप डाउनलोड और कस्टम कमांड टेम्प्लेट प्रबंधित करें। - उपयोग में आसान और उपयोगकर्ता-मित्रवत। - [Material Design 3](https://m3.material.io/) शैली UI, डायनेमिक रंग थीम के साथ। - MAD: UI और लॉजिक को शुद्ध Kotlin में लिखा गया है। एकल गतिविधि, कोई फ़्रैगमेंट नहीं, केवल संयोजनीय गंतव्य। ## ⬇️ डाउनलोड अधिकांश डिवाइसों के लिए, **arm64-v8a** संस्करण की स्थापना की सिफारिश की जाती है। - [GitHub releases](https://github.com/JunkFood02/Seal/releases/latest) से नवीनतम स्थिर संस्करण डाउनलोड करें। - नए फीचर्स और परिवर्तनों का परीक्षण करने के लिए [pre-release](https://github.com/JunkFood02/Seal/releases/) संस्करणों को इंस्टॉल करें। - स्थिर रिलीज़ [F-Droid](https://f-droid.org/packages/com.junkfood.seal/) पर भी उपलब्ध हैं। ## 💬 संपर्क चर्चा, घोषणाओं और रिलीज़ के लिए हमारे [Telegram Channel](https://t.me/seal_app) या [Matrix Space](https://matrix.to/#/#seal-space:matrix.org) से जुड़ें! ## 💖 प्रायोजक

GordonzubleDaniel

Seal हमेशा के लिए मुफ्त और ओपन-सोर्स रहेगा। अगर आपको पसंद आए, तो कृपया [मुझे प्रायोजित करने](https://github.com/sponsors/JunkFood02) पर विचार करें! ## 🤝 योगदान योगदान का स्वागत है! आप [Hosted Weblate](https://hosted.weblate.org/projects/seal/) पर Seal का अनुवाद करने में मदद कर सकते हैं। [![Translate status](https://hosted.weblate.org/widgets/seal/-/strings/multi-auto.svg)](https://hosted.weblate.org/engage/seal/) >[!Note] > >बग रिपोर्ट, फीचर अनुरोध, सवाल, या सुधार के किसी भी विचार को सबमिट करने के लिए, कृपया पहले [CONTRIBUTING.md](https://github.com/JunkFood02/Seal/blob/main/CONTRIBUTING.md) को पढ़ें। ## ⭐️ स्टार इतिहास [![Star History Chart](https://api.star-history.com/svg?repos=JunkFood02/Seal&type=Timeline)](https://star-history.com/#JunkFood02/Seal&Timeline) ## 🧱 क्रेडिट Seal एक सरल GUI है [yt-dlp](https://github.com/yt-dlp/yt-dlp) का, जो [youtubedl-android](https://github.com/yausername/youtubedl-android) पर आधारित है। UI डिज़ाइन और कोड में से कुछ [Read You](https://github.com/Ashinch/ReadYou) और [Music You](https://github.com/Kyant0/MusicYou) से उधार लिए गए हैं। [dvd](https://github.com/yausername/dvd) [Material color utilities](https://github.com/material-foundation/material-color-utilities) [Monet](https://github.com/Kyant0/Monet) ## 📃 लाइसेंस [![GitHub](https://img.shields.io/github/license/JunkFood02/Seal?style=for-the-badge)](https://github.com/JunkFood02/Seal/blob/main/LICENSE) >[!Warning] > >स्रोत कोड के GPLv3 लाइसेंस के तहत लाइसेंस प्राप्त सिवाय, >अन्य सभी पक्षों को Seal के नाम का उपयोग डाउनलोडर ऐप के रूप में करने से रोक दिया गया है, >और Seal के उपोत्पादों के लिए भी यही सच है। >उपोत्पादों में शामिल हैं लेकिन केवल फोर्क्स और अनधिकृत निर्माण तक सीमित नहीं हैं। ================================================ FILE: translations/README-id.md ================================================
# Seal ### Pengunduh Video/Audio untuk Android English   |   Indonesia [![F-Droid](https://img.shields.io/f-droid/v/com.junkfood.seal?color=b4eb12&label=F-Droid&logo=fdroid&logoColor=1f78d2)](https://f-droid.org/en/packages/com.junkfood.seal) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/JunkFood02/Seal?color=black&label=Stable&logo=github)](https://github.com/JunkFood02/Seal/releases/latest/) [![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/JunkFood02/Seal?include_prereleases&label=Preview&logo=Github)](https://github.com/JunkFood02/Seal/releases/) [![Keep a Changelog](https://img.shields.io/badge/Changelog-lightgray?style=flat&color=gray&logo=keep-a-changelog)](https://github.com/JunkFood02/Seal/blob/main/CHANGELOG.md) [![GitHub all releases](https://img.shields.io/github/downloads/JunkFood02/Seal/total?label=Downloads&logo=github)](https://github.com/JunkFood02/Seal/releases/) [![GitHub Repo stars](https://img.shields.io/github/stars/JunkFood02/Seal?color=informational&label=Stars)](https://github.com/JunkFood02/Seal/stargazers) [![Supported Sites](https://img.shields.io/badge/Supported-Sites-9cf.svg?style=flat)](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) [![Telegram Channel](https://img.shields.io/badge/Telegram-Seal-blue?style=flat&logo=telegram)](https://t.me/seal_app) [![Matrix Space](https://img.shields.io/badge/Matrix-Seal-Black?style=flat&color=black&logo=matrix)](https://matrix.to/#/#seal-space:matrix.org)
## 📱 Tangkapan layar

## 📖 Fitur - Unduh file video dan audio dari platform video yang didukung oleh [yt-dlp](https://github.com/yt-dlp/yt-dlp) (sebelumnya youtube-dl). - Menyematkan metadata dan gambar mini video ke dalam file audio yang diekstrak yang didukung oleh [mutagen](https://github.com/quodlibet/mutagen). - Unduh semua video dalam daftar putar dengan satu klik. - Gunakan [aria2c](https://github.com/aria2/aria2) yang disematkan sebagai pengunduh eksternal untuk semua unduhan Anda. - Menyematkan subtitle ke dalam video yang diunduh. - Menjalankan perintah yt-dlp khusus dengan templat. - Mengelola unduhan dalam aplikasi dan templat perintah khusus. - Mudah digunakan dan ramah pengguna. - UI bergaya [Material Design 3](https://m3.material.io/), dengan tema warna yang dinamis. - MAD: UI dan logika yang ditulis dengan Kotlin murni. Aktivitas tunggal, tidak ada fragmen, hanya komposisi tujuan yang dapat dikomposisikan. ## ⬇️ Unduh Untuk sebagian besar perangkat, disarankan untuk menginstal versi **arm64-v8a** dari aplikasi - Unduh versi stabil terbaru dari [GitHub releases](https://github.com/JunkFood02/Seal/releases/latest) - Instal versi [Pra-rilis](https://github.com/JunkFood02/Seal/releases/) untuk membantu kami menguji fitur & perubahan baru - Rilis stabil juga tersedia di [F-Droid](https://f-droid.org/packages/com.junkfood.seal/) ## 💬 Kontak Bergabunglah dengan [Telegram Channel](https://t.me/seal_app) atau [Matrix Space](https://matrix.to/#/#seal-space:matrix.org) untuk diskusi, pengumuman, dan rilis! ## 💖 Sponsor

Gordont1htaJames

Seal akan selalu gratis dan open source untuk semua orang. Jika Anda menyukainya, mohon pertimbangkan untuk [mensponsori saya](https://github.com/sponsors/JunkFood02)! ## 🤝 Berkontribusi Kontribusi dipersilakan! Anda dapat membantu menerjemahkan Seal di [Hosted Weblate](https://hosted.weblate.org/projects/seal/). [![Translate status](https://hosted.weblate.org/widgets/seal/-/strings/multi-auto.svg)](https://hosted.weblate.org/engage/seal/) >**Catatan** > >Untuk mengirimkan laporan bug, permintaan fitur, pertanyaan, atau ide lain untuk perbaikan, silakan baca [CONTRIBUTING.md](https://github.com/JunkFood02/Seal/blob/main/CONTRIBUTING.md) untuk petunjuk dan panduan terlebih dahulu. ## ⭐️ Sejarah bintang [![Star History Chart](https://api.star-history.com/svg?repos=JunkFood02/Seal&type=Timeline)](https://star-history.com/#JunkFood02/Seal&Timeline) ## 🧱 Kredit Seal adalah sebuah GUI sederhana dari [yt-dlp](https://github.com/yt-dlp/yt-dlp), berdasarkan [youtubedl-android](https://github.com/yausername/youtubedl-android) Beberapa desain dan kode UI dipinjam dari [Read You](https://github.com/Ashinch/ReadYou) dan [Music You](https://github.com/Kyant0/MusicYou) [dvd](https://github.com/yausername/dvd) [Utilitas warna material](https://github.com/material-foundation/material-color-utilities) [Monet](https://github.com/Kyant0/Monet) ## 📃 Lisensi [![GitHub](https://img.shields.io/github/license/JunkFood02/Seal?style=for-the-badge)](https://github.com/JunkFood02/Seal/blob/main/LICENSE) >**Peringatan** > >Kecuali untuk kode sumber yang dilisensikan di bawah lisensi GPLv3, >semua pihak lain dilarang menggunakan nama Seal sebagai aplikasi pengunduh, >dan hal yang sama juga berlaku untuk turunan Seal. >Turunannya termasuk tetapi tidak terbatas pada fork dan build tidak resmi. ================================================ FILE: translations/README-it.md ================================================
# Seal ### Video/Audio Downloader per Android

English   |    Italiano

[![F-Droid](https://img.shields.io/f-droid/v/com.junkfood.seal?color=b4eb12&label=F-Droid&logo=fdroid&logoColor=1f78d2)](https://f-droid.org/en/packages/com.junkfood.seal) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/JunkFood02/Seal?color=black&label=Stable&logo=github)](https://github.com/JunkFood02/Seal/releases/latest/) [![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/JunkFood02/Seal?include_prereleases&label=Preview&logo=Github)](https://github.com/JunkFood02/Seal/releases/) [![Keep a Changelog](https://img.shields.io/badge/Changelog-lightgray?style=flat&color=gray&logo=keep-a-changelog)](https://github.com/JunkFood02/Seal/blob/main/CHANGELOG.md) [![GitHub all releases](https://img.shields.io/github/downloads/JunkFood02/Seal/total?label=Downloads&logo=github)](https://github.com/JunkFood02/Seal/releases/) [![GitHub Repo stars](https://img.shields.io/github/stars/JunkFood02/Seal?color=informational&label=Stars)](https://github.com/JunkFood02/Seal/stargazers) [![Supported Sites](https://img.shields.io/badge/Supported-Sites-9cf.svg?style=flat)](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) [![Telegram Channel](https://img.shields.io/badge/Telegram-Seal-blue?style=flat&logo=telegram)](https://t.me/seal_app) [![Matrix Space](https://img.shields.io/badge/Matrix-Seal-Black?style=flat&color=black&logo=matrix)](https://matrix.to/#/#seal-space:matrix.org)
## 📱 Screenshots

## 📖 Funzionalità - Scarica video e audio da tutte le piattaforme supportate da [yt-dlp](https://github.com/yt-dlp/yt-dlp) (conosciuto precedentemente come youtube-dl). - Integra i metadati e le anteprime degli audio attraverso [mutagen](https://github.com/quodlibet/mutagen). - Scarica tutti i video in una playlist con un solo click. - Usa [aria2c](https://github.com/aria2/aria2) integrato per tutti i tuoi download esterni. - Integra i sottotitoli nel video scaricato. - Esegui comandi di yt-dlp personalizzati. - Gestisci i download in-app e i template di comandi personalizzati. - Facile da usare. - UI a tema dinamico [Material Design 3](https://m3.material.io/). - MAD: UI e logica scritti interamente in Kotlin. Attività singola, niente frammentazioni, solo destinazioni componibili. ## ⬇️ Download Per la maggior parte dei dispositivi, é consigliato installare la versione **arm64-v8a** dell'apk. - Scarica l'ultima versione stabile dalle [release di GitHub](https://github.com/JunkFood02/Seal/releases/latest) - Installa la versione [pre-release](https://github.com/JunkFood02/Seal/releases/) per aiutarci a testare nuove funzionalità e trovare bug. - Le versioni stabili sono disponibili anche su [F-Droid](https://f-droid.org/packages/com.junkfood.seal/) ## 💬 Contatti Entra nel nostro [Canale Telegram](https://t.me/seal_app) o nel nostro [spazio Matrix](https://matrix.to/#/#seal-space:matrix.org) per discussioni, annunci e molto altro! ## 💖 Sponsor

mohammed_9456Jas0n2kGordon

Seal rimarra sempre gratis e open-source per tutti. Se ti piace, considera [diventare uno sponsor](https://github.com/sponsors/JunkFood02)! ## 🤝 Contribuire Tutte le contribuzioni sono ben accette! Puoi aiutare a tradurre Seal su [Hosted Weblate](https://hosted.weblate.org/projects/seal/). [![Translate status](https://hosted.weblate.org/widgets/seal/-/strings/multi-auto.svg)](https://hosted.weblate.org/engage/seal/) >**Nota Bene** > >Per inviare report di bug, richieste di feature, domande o qualsiasi altra idea per migliorare, perfavore leggi [CONTRIBUTING.md](https://github.com/JunkFood02/Seal/blob/main/CONTRIBUTING.md) per istruzioni e linee guida. ## ⭐️ Storia delle Stelle [![Star History Chart](https://api.star-history.com/svg?repos=JunkFood02/Seal&type=Timeline)](https://star-history.com/#JunkFood02/Seal&Timeline) ## 🧱 Crediti Seal é una semplice GUI per [yt-dlp](https://github.com/yt-dlp/yt-dlp), basata su [youtubedl-android](https://github.com/yausername/youtubedl-android) Alcune parti del design UI sono prese da [Read You](https://github.com/Ashinch/ReadYou) e [Music You](https://github.com/Kyant0/MusicYou) [dvd](https://github.com/yausername/dvd) [Material color utilities](https://github.com/material-foundation/material-color-utilities) [Monet](https://github.com/Kyant0/Monet) ## 📃 Licenza [![GitHub](https://img.shields.io/github/license/JunkFood02/Seal?style=for-the-badge)](https://github.com/JunkFood02/Seal/blob/main/LICENSE) >**Avvertimento** > >Eccetto per le parti di codice sotto licenza GPLv3, é proibito da parte di terzi di usare il nome di Seal come il nome per un app di download e lo stesso vale per tutti i lavori derivati di Seal. >I derivati includono ma non sono limitati a fork del progetto o build non ufficiali. ================================================ FILE: translations/README-ja.md ================================================

Seal

[![F-Droid](https://img.shields.io/f-droid/v/com.junkfood.seal.svg?logo=F-Droid&color=green&style=flat-square)](https://f-droid.org/en/packages/com.junkfood.seal) [![Releases](https://img.shields.io/github/release/JunkFood02/Seal.svg?logo=github&color=171515&label=stable&style=flat-square)](https://github.com/JunkFood02/Seal/releases/latest) [![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/JunkFood02/Seal?include_prereleases&label=preview&logo=github)](https://github.com/JunkFood02/Seal/releases) [![GitHub all releases](https://img.shields.io/github/downloads/JunkFood02/Seal/total?style=flat-square)](https://github.com/JunkFood02/Seal/releases) [![GitHub Repo stars](https://img.shields.io/github/stars/JunkFood02/Seal?style=flat-square)](https://github.com/JunkFood02/Seal/stargazers) [![Supported Sites](https://img.shields.io/badge/Supported-Sites-9cf.svg?style=flat-square)](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) [![Telegram Channel](https://img.shields.io/badge/Telegram-Seal-blue?style=flat-square&logo=telegram)](https://t.me/seal_app) [![Matrix Space](https://img.shields.io/badge/Matrix-Seal-Black?style=flat-square&color=black&logo=matrix)](https://matrix.to/#/#seal-space:matrix.org)

Android用動画・音声ダウンローダー

English   |    日本語

## 📱 スクリーンショット

## 📖 特徴 - [yt-dlp](https://github.com/yt-dlp/yt-dlp) (旧 youtube-dl)に対応する動画プラットフォームから動画や音声ファイルをダウンロードします - [mutagen](https://github.com/quodlibet/mutagen) で抽出した音声ファイルにメタデータとサムネイルを埋め込みます - 一度クリックするだけで再生リスト内のすべての動画をダウンロードします - [aria2c](https://github.com/aria2/aria2) をすべての動画ダウンロードに使用しています - テンプレートを使って yt-dlp コマンドを指定して実行します - アプリ内でダウンロードとコマンド指定用のテンプレートを管理します - 簡単、また使いやすいです - [Material Design 3](https://m3.material.io/) のUIに [ダイナミックカラー](https://m3.material.io/foundations/customization)のテーマを使用しています - MAD: UIやロジックはすべてKotlinで書かれています。シングルアクティビティ、フラグメントなし、composable destinationsのみで構成されています ## ⬇️ ダウンロード ほとんどの端末上では、**arm64-v8a**版のapkのインストールをお勧めします。 - [GitHub releases](https://github.com/JunkFood02/Seal/releases/latest)から最新の安定板をダウンロードしてください - 新機能や変更点をテストをするには[プレリリース版](https://github.com/JunkFood02/Seal/releases/)をインストールしてください - F-Droid からも安定板をダウンロードできます [F-droidで入手](https://f-droid.org/packages/com.junkfood.seal/) ## 💬 連絡先 私たちの [Telegram Channel](https://t.me/seal_app) もしくは [Matrix Space](https://matrix.to/#/#seal-space:matrix.org) で議論に参加したり、お知らせなどをご覧ください! ## 💖 スポンサー Seal は誰にとっても常にフリーでオープンソースです。気に入っていただけたら[スポンサーになることもご検討ください](https://github.com/sponsors/JunkFood02)! ## 🤝 貢献 あらゆる貢献を歓迎します! Sealの翻訳を [Hosted Weblate](https://hosted.weblate.org/projects/seal/) で手伝ってください! [![翻訳状況](https://hosted.weblate.org/widgets/seal/-/strings/multi-auto.svg)](https://hosted.weblate.org/engage/seal/) >**備考** > >バグ報告、機能要望、質問、その他改善のためのアイデアを投稿する場合は、まず [CONTRIBUTING.md](https://github.com/JunkFood02/Seal/blob/main/CONTRIBUTING.md) を読んで、手順とガイドラインを確認してください。 ## ⭐️星の推移 [![Star History Chart](https://api.star-history.com/svg?repos=JunkFood02/Seal&type=Timeline)](https://star-history.com/#JunkFood02/Seal&Timeline) ## 🧱 クレジット Sealは [youtubedl-android](https://github.com/yausername/youtubedl-android) をベースにした [yt-dlp](https://github.com/yt-dlp/yt-dlp) のシンプルなGUIです UIとソースコードの一部は、[Read You](https://github.com/Ashinch/ReadYou) や [Music You](https://github.com/Kyant0/MusicYou) [dvd](https://github.com/yausername/dvd) [Material color utilities](https://github.com/material-foundation/material-color-utilities) [Monet](https://github.com/Kyant0/Monet) などから借用しています ## 📃ライセンス [![GitHub](https://img.shields.io/github/license/JunkFood02/Seal?style=for-the-badge)](https://github.com/JunkFood02/Seal/blob/main/LICENSE) ================================================ FILE: translations/README-pt.md ================================================
# Seal ### Baixador de vídeos/áudio para Android Inglês   |    简体中文   |    繁體中文   |    العربية   |    Português   |    Українська   |    ภาษาไทย   |    فارسی   |    Italiano   |    Azərbaycanca   |    Русский   |    Српски   |    日本語   |    Indonesia   |    हिंदी [![F-Droid](https://img.shields.io/f-droid/v/com.junkfood.seal?color=b4eb12&label=F-Droid&logo=fdroid&logoColor=1f78d2)](https://f-droid.org/en/packages/com.junkfood.seal) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/JunkFood02/Seal?color=black&label=Stable&logo=github)](https://github.com/JunkFood02/Seal/releases/latest/) [![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/JunkFood02/Seal?include_prereleases&label=Preview&logo=Github)](https://github.com/JunkFood02/Seal/releases/) [![Keep a Changelog](https://img.shields.io/badge/Changelog-lightgray?style=flat&color=gray&logo=keep-a-changelog)](https://github.com/JunkFood02/Seal/blob/main/CHANGELOG.md) [![GitHub all releases](https://img.shields.io/github/downloads/JunkFood02/Seal/total?label=Downloads&logo=github)](https://github.com/JunkFood02/Seal/releases/) [![GitHub Repo stars](https://img.shields.io/github/stars/JunkFood02/Seal?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIC05NjAgOTYwIDk2MCIgd2lkdGg9IjI0IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxwYXRoIGQ9Im0zNTQtMjQ3IDEyNi03NiAxMjYgNzctMzMtMTQ0IDExMS05Ni0xNDYtMTMtNTgtMTM2LTU4IDEzNS0xNDYgMTMgMTExIDk3LTMzIDE0M1pNMjMzLTgwbDY1LTI4MUw4MC01NTBsMjg4LTI1IDExMi0yNjUgMTEyIDI2NSAyODggMjUtMjE4IDE4OSA2NSAyODEtMjQ3LTE0OUwyMzMtODBabTI0Ny0zNTBaIiBzdHlsZT0iZmlsbDogcmdiKDI0NSwgMjI3LCA2Nik7Ii8%2BCjwvc3ZnPg%3D%3D&color=%23f8e444)](https://github.com/JunkFood02/Seal/stargazers) [![Supported-Sites](https://img.shields.io/badge/Sites-9cf?style=flat&logo=data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyBoZWlnaHQ9IjI0cHgiIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0cHgiIGZpbGw9IiNGRkZGRkYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTAgMGgyNHYyNEgwVjB6IiBmaWxsPSJub25lIi8+CiAgPHBhdGggZD0iTTExLjk5IDJDNi40NyAyIDIgNi40OCAyIDEyczQuNDcgMTAgOS45OSAxMEMxNy41MiAyMiAyMiAxNy41MiAyMiAxMlMxNy41MiAyIDExLjk5IDJ6bTYuOTMgNmgtMi45NWMtLjMyLTEuMjUtLjc4LTIuNDUtMS4zOC0zLjU2IDEuODQuNjMgMy4zNyAxLjkxIDQuMzMgMy41NnpNMTIgNC4wNGMuODMgMS4yIDEuNDggMi41MyAxLjkxIDMuOTZoLTMuODJjLjQzLTEuNDMgMS4wOC0yLjc2IDEuOTEtMy45NnpNNC4yNiAxNEM0LjEgMTMuMzYgNCAxMi42OSA0IDEycy4xLTEuMzYuMjYtMmgzLjM4Yy0uMDguNjYtLjE0IDEuMzItLjE0IDJzLjA2IDEuMzQuMTQgMkg0LjI2em0uODIgMmgyLjk1Yy4zMiAxLjI1Ljc4IDIuNDUgMS4zOCAzLjU2LTEuODQtLjYzLTMuMzctMS45LTQuMzMtMy41NnptMi45NS04SDUuMDhjLjk2LTEuNjYgMi40OS0yLjkzIDQuMzMtMy41NkM4LjgxIDUuNTUgOC4zNSA2Ljc1IDguMDMgOHpNMTIgMTkuOTZjLS44My0xLjItMS40OC0yLjUzLTEuOTEtMy45NmgzLjgyYy0uNDMgMS40My0xLjA4IDIuNzYtMS45MSAzLjk2ek0xNC4zNCAxNEg5LjY2Yy0uMDktLjY2LS4xNi0xLjMyLS4xNi0ycy4wNy0xLjM1LjE2LTJoNC42OGMuMDkuNjUuMTYgMS4zMi4xNiAycy0uMDcgMS4zNC0uMTYgMnptLjI1IDUuNTZjLjYtMS4xMSAxLjA2LTIuMzEgMS4zOC0zLjU2aDIuOTVjLS45NiAxLjY1LTIuNDkgMi45My00LjMzIDMuNTZ6TTE2LjM2IDE0Yy4wOC0uNjYuMTQtMS4zMi4xNC0ycy0uMDYtMS4zNC0uMTQtMmgzLjM4Yy4xNi42NC4yNiAxLjMxLjI2IDJzLS4xIDEuMzYtLjI2IDJoLTMuMzh6IiBzdHlsZT0iZmlsbDogcmdiKDE2MiwgMTk4LCAyMzQpOyIvPgo8L3N2Zz4=&label=Supported)](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) [![Telegram Channel](https://img.shields.io/badge/Telegram-Seal-blue?style=flat&logo=telegram)](https://t.me/seal_app) [![Matrix](https://img.shields.io/matrix/seal-space%3Amatrix.org?server_fqdn=matrix.org&style=flat&logo=element&label=Matrix&color=%230DBD8B) ](https://matrix.to/#/#seal-space:matrix.org)
## 📱 Capturas de tela

## 📖 Funcionalidades - Baixe arquivos de vídeo e áudio de plataformas suportadas pelo [yt-dlp](https://github.com/yt-dlp/yt-dlp) (antigo youtube-dl) - Adicione metadados e capa de vídeo suportados pelo [mutagen](https://github.com/quodlibet/mutagen) nos arquivos de áudio extraidos - Baixe todos vídeos de uma playlist em apenas um clique. - Use o [aria2c](https://github.com/aria2/aria2) pré-instalado como baixador externo para todos os seus downloads; - Incorpore legendas nos vídeos baixados. - Execute comandos do `yt-dlp` customizados, ou a partir de modelos. - Gerencie downloads e modelos de comando customizados dentro do aplicativo. - Simples e intuitivo - Estilo de interface [Material Design 3](https://m3.material.io/), com temas de cores dinâmicas - MAD: UI e lógica escrita em Kotlin puro. Atividade única, sem fragmentos, apenas destinos combináveis. ## ⬇️ Download Para a maioria dos dispositivos, é recomendado usar a versão **arm64-v8a** dos apks - Baixe a versão estável mais recente do [GitHub releases](https://github.com/JunkFood02/Seal/releases/latest) - Instale versões de "[pre-release](https://github.com/JunkFood02/Seal/releases/)" para testar novas funcionalidades e mudanças - Versões estáveis disponíveis no [F-Droid](https://f-droid.org/packages/com.junkfood.seal/) ## 💬 Contato Entre no nosso [Canal do Telegram](https://t.me/seal_app) ou [Espaço no Matrix](https://matrix.to/#/#seal-space:matrix.org) para discuções, anúncios e versões novas! ## 💖 Patrocinadores

zubleDaniel

O seal vai ser sempre grátis e de código aberto pra todo mundo. Se você curte o app, considere [me patrocinar](https://github.com/sponsors/JunkFood02)! ## 🤝 Contribuir Contribuições são bem-vindas!! Você pode ajudar a traduzir o Seal no [Weblate Hosteado](https://hosted.weblate.org/projects/seal/). [![Status de tradução](https://hosted.weblate.org/widgets/seal/-/strings/multi-auto.svg)](https://hosted.weblate.org/engage/seal/) >[!Nota] > >Para enviar bug reports, sugestões de funcionalidades, questões, ou ideias para melhorias, por favor leia [CONTRIBUTING.md](https://github.com/JunkFood02/Seal/blob/main/CONTRIBUTING.md) para instruções e regras primeiro. ## ⭐️ Linha de tempo de Stars [![Star History Chart](https://api.star-history.com/svg?repos=JunkFood02/Seal&type=Timeline)](https://star-history.com/#JunkFood02/Seal&Timeline) ## 🧱 Creditos Seal é uma interface simples do [yt-dlp](https://github.com/yt-dlp/yt-dlp), com o código baseado no [youtubedl-android](https://github.com/yausername/youtubedl-android ) Alguns dos designs e códigos da interface do usuário são emprestados de [Read You](https://github.com/Ashinch/ReadYou) e [Music You](https://github.com/Kyant0/MusicYou) [dvd](https://github.com/yausername/dvd) [Material color utilities](https://github.com/material-foundation/material-color-utilities) [Monet](https://github.com/Kyant0/Monet) ## 📃 License [![GitHub](https://img.shields.io/github/license/JunkFood02/Seal?style=for-the-badge)](https://github.com/JunkFood02/Seal/blob/main/LICENSE) >[!Warning] > >Exceto pelo código-fonte que é licensiado em GPLv3, >todas as outras fontes estão proibidas de usarem o nome Seal como um aplicativo baixador, >e o mesmo se aplica para os aplicativos derivados do seal. >Derivações incluem mas não se limitam a forks e builds não oficiais.. ================================================ FILE: translations/README-ru.md ================================================
# Seal ### Загрузчик видео и аудио файлов на Android

English   |    Русский

[![F-Droid](https://img.shields.io/f-droid/v/com.junkfood.seal?color=b4eb12&label=F-Droid&logo=fdroid&logoColor=1f78d2)](https://f-droid.org/ru/packages/com.junkfood.seal) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/JunkFood02/Seal?color=black&label=Stable&logo=github)](https://github.com/JunkFood02/Seal/releases/latest/) [![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/JunkFood02/Seal?include_prereleases&label=Preview&logo=Github)](https://github.com/JunkFood02/Seal/releases/) [![Keep a Changelog](https://img.shields.io/badge/Changelog-lightgray?style=flat&color=gray&logo=keep-a-changelog)](https://github.com/JunkFood02/Seal/blob/main/CHANGELOG.md) [![GitHub all releases](https://img.shields.io/github/downloads/JunkFood02/Seal/total?label=Downloads&logo=github)](https://github.com/JunkFood02/Seal/releases/) [![GitHub Repo stars](https://img.shields.io/github/stars/JunkFood02/Seal?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIC05NjAgOTYwIDk2MCIgd2lkdGg9IjI0IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxwYXRoIGQ9Im0zNTQtMjQ3IDEyNi03NiAxMjYgNzctMzMtMTQ0IDExMS05Ni0xNDYtMTMtNTgtMTM2LTU4IDEzNS0xNDYgMTMgMTExIDk3LTMzIDE0M1pNMjMzLTgwbDY1LTI4MUw4MC01NTBsMjg4LTI1IDExMi0yNjUgMTEyIDI2NSAyODggMjUtMjE4IDE4OSA2NSAyODEtMjQ3LTE0OUwyMzMtODBabTI0Ny0zNTBaIiBzdHlsZT0iZmlsbDogcmdiKDI0NSwgMjI3LCA2Nik7Ii8%2BCjwvc3ZnPg%3D%3D&color=%23f8e444)](https://github.com/JunkFood02/Seal/stargazers) [![Supported-Sites](https://img.shields.io/badge/Sites-9cf?style=flat&logo=data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyBoZWlnaHQ9IjI0cHgiIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0cHgiIGZpbGw9IiNGRkZGRkYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPHBhdGggZD0iTTAgMGgyNHYyNEgwVjB6IiBmaWxsPSJub25lIi8+CiAgPHBhdGggZD0iTTExLjk5IDJDNi40NyAyIDIgNi40OCAyIDEyczQuNDcgMTAgOS45OSAxMEMxNy41MiAyMiAyMiAxNy41MiAyMiAxMlMxNy41MiAyIDExLjk5IDJ6bTYuOTMgNmgtMi45NWMtLjMyLTEuMjUtLjc4LTIuNDUtMS4zOC0zLjU2IDEuODQuNjMgMy4zNyAxLjkxIDQuMzMgMy41NnpNMTIgNC4wNGMuODMgMS4yIDEuNDggMi41MyAxLjkxIDMuOTZoLTMuODJjLjQzLTEuNDMgMS4wOC0yLjc2IDEuOTEtMy45NnpNNC4yNiAxNEM0LjEgMTMuMzYgNCAxMi42OSA0IDEycy4xLTEuMzYuMjYtMmgzLjM4Yy0uMDguNjYtLjE0IDEuMzItLjE0IDJzLjA2IDEuMzQuMTQgMkg0LjI2em0uODIgMmgyLjk1Yy4zMiAxLjI1Ljc4IDIuNDUgMS4zOCAzLjU2LTEuODQtLjYzLTMuMzctMS45LTQuMzMtMy41NnptMi45NS04SDUuMDhjLjk2LTEuNjYgMi40OS0yLjkzIDQuMzMtMy41NkM4LjgxIDUuNTUgOC4zNSA2Ljc1IDguMDMgOHpNMTIgMTkuOTZjLS44My0xLjItMS40OC0yLjUzLTEuOTEtMy45NmgzLjgyYy0uNDMgMS40My0xLjA4IDIuNzYtMS45MSAzLjk2ek0xNC4zNCAxNEg5LjY2Yy0uMDktLjY2LS4xNi0xLjMyLS4xNi0ycy4wNy0xLjM1LjE2LTJoNC42OGMuMDkuNjUuMTYgMS4zMi4xNiAycy0uMDcgMS4zNC0uMTYgMnptLjI1IDUuNTZjLjYtMS4xMSAxLjA2LTIuMzEgMS4zOC0zLjU2aDIuOTVjLS45NiAxLjY1LTIuNDkgMi45My00LjMzIDMuNTZ6TTE2LjM2IDE0Yy4wOC0uNjYuMTQtMS4zMi4xNC0ycy0uMDYtMS4zNC0uMTQtMmgzLjM4Yy4xNi42NC4yNiAxLjMxLjI2IDJzLS4xIDEuMzYtLjI2IDJoLTMuMzh6IiBzdHlsZT0iZmlsbDogcmdiKDE2MiwgMTk4LCAyMzQpOyIvPgo8L3N2Zz4=&label=Supported)](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) [![Telegram Channel](https://img.shields.io/badge/Telegram-Seal-blue?style=flat&logo=telegram)](https://t.me/seal_app) [![Matrix](https://img.shields.io/matrix/seal-space%3Amatrix.org?server_fqdn=matrix.org&style=flat&logo=element&label=Matrix&color=%230DBD8B) ](https://matrix.to/#/#seal-space:matrix.org)
## 📱 Снимки экрана

## 📖 Возможности - Загрузка видео и аудио файлов с видео платформ, поддерживаемых [yt-dlp](https://github.com/yt-dlp/yt-dlp) (ранее youtube-dl). - Добавляйте метаданные и превью в загружаемые аудио файлы с помощью [mutagen](https://github.com/quodlibet/mutagen). - Загрузка всех видео из плейлиста в один клик - Использует встроенный [aria2c](https://github.com/aria2/aria2) как внешний загрузчик для всех ваших загрузок - Можно встроить субтитры в загружаемое видео - Контроль над загрузками в приложении и кастомные шаблоны команд - Просмотр и управление загрузками в приложении - Простой в использовании - Стилизованный под [Material Design 3](https://m3.material.io/), с динамической цветовой схемой - Интерфейс и его поведение написано на Kotlin. Один активити, без фрагментов, только composable destinations. ## ⬇️ Установка Для большинства устройств рекомендовано устанавливать версию apk **arm64-v8a** - Скачать последнюю стабильную версию со [страницы с релизами](https://github.com/JunkFood02/Seal/releases/latest) - Установить [пре-релиз](https://github.com/JunkFood02/Seal/releases/) чтобы помочь протестировать нам новые функции и изменения - Стабильные релизы также доступны на [F-Droid](https://f-droid.org/packages/com.junkfood.seal/) ## 💬 Связаться Присоединяйтесь к нашему [Telegram каналу](https://t.me/seal_app) или [Matrix Space](https://matrix.to/#/#seal-space:matrix.org) для рассуждений, анонсов и релизов! ## 💖 Спонсоры Seal всегда будет бесплатным проектом с открытым исходным кодом для каждого. Если вам это нравится, пожалуйста рассмотрите возможность [поддержать меня](https://github.com/sponsors/JunkFood02)! ## 🤝 Помочь с переводом Помощь приветствуется! Вы можете принять участие в переводе Seal на [Hosted Weblate](https://hosted.weblate.org/projects/seal/). [![Translate status](https://hosted.weblate.org/widgets/seal/-/multi-auto.svg)](https://hosted.weblate.org/engage/seal/) >[!Note] > > Чтобы отсылать нам баги, запросы на добавление новых функций или любые другие идеи, которые помогут проекту, сперва прочитайте [CONTRIBUTING.md](https://github.com/JunkFood02/Seal/blob/main/CONTRIBUTING.md) для важной информации и инструкций. ## ⭐️ График роста кол-ва звёздочек [![Star History Chart](https://api.star-history.com/svg?repos=JunkFood02/Seal&type=Timeline)](https://star-history.com/#JunkFood02/Seal&Timeline) ## 🧱 Особая благодарность Seal - это простой интерфейс для [yt-dlp](https://github.com/yt-dlp/yt-dlp), созданный на базе [youtubedl-android](https://github.com/yausername/youtubedl-android) Некоторые элементы дизайна и кода были заимствованны у [Read You](https://github.com/Ashinch/ReadYou) и [Music You](https://github.com/Kyant0/MusicYou) [dvd](https://github.com/yausername/dvd) [Material color utilities](https://github.com/material-foundation/material-color-utilities) [Monet](https://github.com/Kyant0/Monet) ## 📃 Лицензия [![GitHub](https://img.shields.io/github/license/JunkFood02/Seal?style=for-the-badge)](https://github.com/JunkFood02/Seal/blob/main/LICENSE) >[!Warning] > >За исключением исходного кода, лицензированного по лицензии GPLv3, >всем остальным сторонам запрещено использовать название Seal в качестве загрузчика приложений, >то же самое распространяется на производные Seal. >Деривативы разрешены, но они не ограничиваются в производных и неофициальных сборках. ================================================ FILE: translations/README-sr.md ================================================
# Seal ### Апликација за преузимање видео и аудио снимака за Android

English   |    Српски

[![F-Droid](https://img.shields.io/f-droid/v/com.junkfood.seal?color=b4eb12&label=F-Droid&logo=fdroid&logoColor=1f78d2)](https://f-droid.org/en/packages/com.junkfood.seal) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/JunkFood02/Seal?color=black&label=Stable&logo=github)](https://github.com/JunkFood02/Seal/releases/latest/) [![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/JunkFood02/Seal?include_prereleases&label=Preview&logo=Github)](https://github.com/JunkFood02/Seal/releases/) [![Keep a Changelog](https://img.shields.io/badge/Changelog-lightgray?style=flat&color=gray&logo=keep-a-changelog)](https://github.com/JunkFood02/Seal/blob/main/CHANGELOG.md) [![GitHub all releases](https://img.shields.io/github/downloads/JunkFood02/Seal/total?label=Downloads&logo=github)](https://github.com/JunkFood02/Seal/releases/) [![GitHub Repo stars](https://img.shields.io/github/stars/JunkFood02/Seal?color=informational&label=Stars)](https://github.com/JunkFood02/Seal/stargazers) [![Supported Sites](https://img.shields.io/badge/Supported-Sites-9cf.svg?style=flat)](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) [![Telegram Channel](https://img.shields.io/badge/Telegram-Seal-blue?style=flat&logo=telegram)](https://t.me/seal_app) [![Matrix Space](https://img.shields.io/badge/Matrix-Seal-Black?style=flat&color=black&logo=matrix)](https://matrix.to/#/#seal-space:matrix.org)
## 📱 Снимци екрана

## 📖 Карактеристике - Преузмите видео и аудио снимке с платформи за видео снимке које су подржане од [yt-dlp](https://github.com/yt-dlp/yt-dlp) (раније youtube-dl) платформе. - Уградите метаподатке и сличицу видео снимка у екстрактоване аудио фајлове који су подржани од [mutagen](https://github.com/quodlibet/mutagen) модула. - Преузмите све видео снимке с плејлисте једним кликом. - Користите уграђени [aria2c](https://github.com/aria2/aria2) као спољни програм за преузимање, за сва преузимања. - Уградите титлове у преузете видео снимке. - Извршите прилагођене yt-dlp команде са шаблонима. - Управљајте преузимањима у апликацији и прилагођеним командним шаблонима. - Једноставно за употребу и прилагођено кориснику. - [Material Design 3](https://m3.material.io/) стил корисничког интерфејса, са динамичком темом боја. - MAD: Кориснички интерфејс и логика написана користећи Kotlin. Једна активност, без фрагмената, само састављајућа одредишта. ## ⬇️ Преузимање За већину уређаја, препоручује се инсталирање **arm64-v8a** верзије APK-ова - Преузмите најновију стабилну верзију са [GitHub издања](https://github.com/JunkFood02/Seal/releases/latest) - Инсталирајте [бета издања](https://github.com/JunkFood02/Seal/releases/) апликације да бисте нам помогли да тестирамо нове функције и промене - Стабилна издања су такође доступна у продавници апликација [F-Droid](https://f-droid.org/packages/com.junkfood.seal/) ## 💬 Контакт Придружите се нашем [Telegram каналу](https://t.me/seal_app) или [Matrix простору](https://matrix.to/#/#seal-space:matrix.org) за дискусију, најаве и издања. ## 💖 Спонзори

Seal ће увек бити бесплатан и отвореног кода за све. Ако вам се свиђа, размислите о томе да ме [спонзоришете](https://github.com/sponsors/JunkFood02)! ## 🤝 Допринос Доприноси су добродошли! Можете помоћи у превођењу апликације Seal на веб-сајту [Hosted Weblate](https://hosted.weblate.org/projects/seal/). [![Translate status](https://hosted.weblate.org/widgets/seal/-/strings/multi-auto.svg)](https://hosted.weblate.org/engage/seal/) >**Напомена** > >За слање извештаја о грешкама, захтева за функције, питања или било које друге идеје за побољшање апликације, прво прочитајте [CONTRIBUTING.md](https://github.com/JunkFood02/Seal/blob/main/CONTRIBUTING.md) за упутства и смернице. ## ⭐️ Графикон раста броја звездица [![Star History Chart](https://api.star-history.com/svg?repos=JunkFood02/Seal&type=Timeline)](https://star-history.com/#JunkFood02/Seal&Timeline) ## 🧱 Кредити Seal је једноставан графички кориснички интерфејс за [yt-dlp](https://github.com/yt-dlp/yt-dlp), заснован на [youtubedl-android](https://github.com/yausername/youtubedl-android) Неки дизајни корисничког интерфејса и кодови су позајмљени од [Read You](https://github.com/Ashinch/ReadYou) и [Music You](https://github.com/Kyant0/MusicYou) [dvd](https://github.com/yausername/dvd) [Material color utilities](https://github.com/material-foundation/material-color-utilities) [Monet](https://github.com/Kyant0/Monet) ## 📃 Лиценца [![GitHub](https://img.shields.io/github/license/JunkFood02/Seal?style=for-the-badge)](https://github.com/JunkFood02/Seal/blob/main/LICENSE) >**Упозорење** > >Осим изворног кода, лиценцираног под GPLv3 лиценцом, >свим другим странама је забрањено да користе назив апликације Seal као програм за преузимање, >а исто важи и за деривате апликације Seal. >Деривати укључују, али нису ограничени на форкове кода и незваничне верзије. ================================================ FILE: translations/README-th.md ================================================

Seal

[![F-Droid](https://img.shields.io/f-droid/v/com.junkfood.seal.svg?logo=F-Droid&color=green&style=flat-square)](https://f-droid.org/en/packages/com.junkfood.seal) [![Releases](https://img.shields.io/github/release/JunkFood02/Seal.svg?logo=github&color=171515&label=stable&style=flat-square)](https://github.com/JunkFood02/Seal/releases/latest) [![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/JunkFood02/Seal?include_prereleases&label=preview&logo=github)](https://github.com/JunkFood02/Seal/releases) [![GitHub all releases](https://img.shields.io/github/downloads/JunkFood02/Seal/total?style=flat-square)](https://github.com/JunkFood02/Seal/releases) [![GitHub Repo stars](https://img.shields.io/github/stars/JunkFood02/Seal?style=flat-square)](https://github.com/JunkFood02/Seal/stargazers) [![Supported Sites](https://img.shields.io/badge/Supported-Sites-9cf.svg?style=flat-square)](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) [![Telegram Channel](https://img.shields.io/badge/Telegram-Seal-blue?style=flat-square&logo=telegram)](https://t.me/seal_app) [![Matrix Space](https://img.shields.io/badge/Matrix-Seal-Black?style=flat-square&color=black&logo=matrix)](https://matrix.to/#/#seal-space:matrix.org)

แอพดาวน์โหลดวิดิโอสำหรับ Android

English   |    ภาษาไทย

## 📱 Screenshots

## 📖 คุณสมบัติ - ดาวน์โหลดวิดีโอและไฟล์เสียงจากแพลตฟอร์มวิดีโอที่รองรับโดย [yt-dlp](https://github.com/yt-dlp/yt-dlp) (เดิมคือ youtube-dl) - ฝังข้อมูลเมตาและภาพขนาดย่อของวิดีโอลงในไฟล์เสียงที่แยกออกมาซึ่งรองรับโดย [mutagen](https://github.com/quodlibet/mutagen) - ดาวน์โหลดวิดีโอทั้งหมดในเพลย์ลิสต์ได้ด้วยคลิกเดียว - ใช้ [aria2c](https://github.com/aria2/aria2) แบบฝังเป็นตัวดาวน์โหลดภายนอกสำหรับการดาวน์โหลดทั้งหมดของคุณ - ฝังคำบรรยายลงในวิดีโอที่ดาวน์โหลด - ดำเนินการคำสั่ง yt-dlp ที่กำหนดเองด้วยเทมเพลต - จัดการการดาวน์โหลดในแอปและเทมเพลตคำสั่งที่กำหนดเอง - ใช้งานง่ายและเป็นมิตรกับผู้ใช้ - [Material Design 3](https://m3.material.io/) UI สไตล์พร้อมธีมสีแบบไดนามิก - MAD: UI และตรรกะที่เขียนด้วย Kotlin ล้วนๆ กิจกรรมเดียว ไม่มีแฟรกเมนต์ มีเพียงปลายทางที่รวบรวมได้ ## ⬇️ ดาวน์โหลด สำหรับอุปกรณ์ส่วนใหญ่ ขอแนะนำให้ติดตั้ง APK เวอร์ชัน **arm64-v8a** - ดาวน์โหลดเวอร์ชันเสถียรล่าสุดจาก [GitHub releases](https://github.com/JunkFood02/Seal/releases/latest) - ติดตั้งเวอร์ชัน [ก่อนเผยแพร่](https://github.com/JunkFood02/Seal/releases/) เพื่อช่วยเราทดสอบฟีเจอร์และการเปลี่ยนแปลงใหม่ๆ - เวอร์ชั้นเสถียรก็มีอยู่ใน F-Droid เช่นกัน [ดาวน์โหลดบน F-Droid](https://f-droid.org/packages/com.junkfood.seal/) ## 💬 ติดต่อ เข้าร่วม [Telegram Channel](https://t.me/seal_app) หรือ [Matrix Space](https://matrix.to/#/#seal-space:matrix.org) เพื่อพูดคุย, ฟังประกาศ, และเผยแพร่! ## 🤝 ร่วมสมทบทุน คุณสามารถสนับสนุน Seal ได้! คุณสามารถช่วยแปล Seal ได้ที่ [Hosted Weblate](https://hosted.weblate.org/projects/seal/) [![สถานะการแปล](https://hosted.weblate.org/widgets/seal/-/strings/multi-auto.svg)](https://hosted.weblate.org/engage/seal/) >**หมายเหตุ** หากต้องการส่งรายงานข้อบกพร่อง คำขอฟีเจอร์ คำถาม หรือแนวคิดอื่นๆ เพื่อปรับปรุง โปรดอ่านคำแนะนำและหลักเกณฑ์ที่ [CONTRIBUTING.md](https://github.com/JunkFood02/Seal/blob/main/CONTRIBUTING.md) . ## ⭐️ประวัติ Star [![Star History Chart](https://api.star-history.com/svg?repos=JunkFood02/Seal&type=Timeline)](https://star-history.com/#JunkFood02/Seal&Timeline) ## 🧱 เครดิต Seal เป็น GUI อย่างง่ายของ [yt-dlp](https://github.com/yt-dlp/yt-dlp) โดยอ้างอิงจาก [youtubedl-android](https://github.com/yausername/youtubedl-android ) การออกแบบ UI และโค้ดบางส่วนยืมมาจาก [Read You](https://github.com/Ashinch/ReadYou) และ [Music You](https://github.com/Kyant0/MusicYou) [ดีวีดี](https://github.com/yausername/dvd) [ยูทิลิตี้สีวัสดุ](https://github.com/material-foundation/material-color-utilities) [โมเนต์](https://github.com/Kyant0/Monet) ## 📃ใบอนุญาติ [![GitHub](https://img.shields.io/github/license/JunkFood02/Seal?style=for-the-badge)](https://github.com/JunkFood02/Seal/blob/main/LICENSE) ================================================ FILE: translations/README-ua.md ================================================
# Seal ### Завантажувач відео та аудіо для Android

English   |    Українська

[![F-Droid](https://img.shields.io/f-droid/v/com.junkfood.seal?color=b4eb12&label=F-Droid&logo=fdroid&logoColor=1f78d2)](https://f-droid.org/en/packages/com.junkfood.seal) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/JunkFood02/Seal?color=black&label=Stable&logo=github)](https://github.com/JunkFood02/Seal/releases/latest/) [![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/JunkFood02/Seal?include_prereleases&label=Preview&logo=Github)](https://github.com/JunkFood02/Seal/releases/) [![Keep a Changelog](https://img.shields.io/badge/Changelog-lightgray?style=flat&color=gray&logo=keep-a-changelog)](https://github.com/JunkFood02/Seal/blob/main/CHANGELOG.md) [![GitHub all releases](https://img.shields.io/github/downloads/JunkFood02/Seal/total?label=Downloads&logo=github)](https://github.com/JunkFood02/Seal/releases/) [![GitHub Repo stars](https://img.shields.io/github/stars/JunkFood02/Seal?color=informational&label=Stars)](https://github.com/JunkFood02/Seal/stargazers) [![Supported Sites](https://img.shields.io/badge/Supported-Sites-9cf.svg?style=flat)](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) [![Telegram Channel](https://img.shields.io/badge/Telegram-Seal-blue?style=flat&logo=telegram)](https://t.me/seal_app) [![Matrix Space](https://img.shields.io/badge/Matrix-Seal-Black?style=flat&color=black&logo=matrix)](https://matrix.to/#/#seal-space:matrix.org)
## 📱 Скріншоти

## 📖 Особливості - Завантажуйте відео та аудіо файли з сайтів, які підтримуються yt-dlp (тобто youtube-dl) - Додавайте метадані і прев'ю у завантажені аудіо файли завдяки mutagen - Завантажуйте усі відео з плейлиста в один клік - Використовуйте вбудований aria2c як завантажувач для всіх своїх завантажень - Додавайте субтитри у завантажені відео - Використовуйте власні команди yt-dlp завдяки шаблонам - Переглядайте та керуйте завантаженнями у застосунку - Простий та приємний у використанні - Стилізований під Material Design 3 інтерфейс з динамічною колірною схемою - Круте: Інтерфейс та логіка написані на Kotlin. Один актівіті, без фрагментів, лише composable destinations. ## ⬇️ Завантажити [Завантажте з F-Droid](https://f-droid.org/packages/com.junkfood.seal/) Або завантажте останні APK на сторінці [з релізами.](https://github.com/JunkFood02/Seal/releases/) ## 🤝 Допомога Співпраця вітається! Допоможіть перекласти Seal на [Hosted Weblate](https://hosted.weblate.org/projects/seal/). [![Translate status](https://hosted.weblate.org/widgets/seal/-/multi-auto.svg)](https://hosted.weblate.org/engage/seal/) >**Нотатка** > Щоб надіслати звіти про помилки, запити на додавання нових функцій, запитання чи будь-які інші ідеї щодо покращення, спершу прочитайте [CONTRIBUTING.md](https://github.com/JunkFood02/Seal/blob/main/CONTRIBUTING.md) за інструкціями та вказівками. ## ⭐️ Історія зірочок [![Star History Chart](https://api.star-history.com/svg?repos=JunkFood02/Seal&type=Timeline)](https://star-history.com/#JunkFood02/Seal&Timeline) ## 🧱 Подяки Seal - це простий інтерфейс для [yt-dlp](https://github.com/yt-dlp/yt-dlp), який був створений на базі [youtubedl-android](https://github.com/yausername/youtubedl-android) Деякий код та елементи інтерфейсу взяті з [Read You](https://github.com/Ashinch/ReadYou) та [Music You](https://github.com/Kyant0/MusicYou) [dvd](https://github.com/yausername/dvd) [Material color utilities](https://github.com/material-foundation/material-color-utilities) ## 📃 Ліцензія [![GitHub](https://img.shields.io/github/license/JunkFood02/Seal?style=for-the-badge)](https://github.com/JunkFood02/Seal/blob/main/LICENSE) ================================================ FILE: translations/README-zh_Hans.md ================================================
# Seal ### 一个简单的 Android 视频/音频下载器,使用 Jetpack Compose 进行开发

English   |   简体中文

[![F-Droid](https://img.shields.io/f-droid/v/com.junkfood.seal?color=b4eb12&label=F-Droid&logo=fdroid&logoColor=1f78d2)](https://f-droid.org/en/packages/com.junkfood.seal) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/JunkFood02/Seal?color=black&label=Stable&logo=github)](https://github.com/JunkFood02/Seal/releases/latest/) [![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/JunkFood02/Seal?include_prereleases&label=Preview&logo=Github)](https://github.com/JunkFood02/Seal/releases/) [![Keep a Changelog](https://img.shields.io/badge/Changelog-lightgray?style=flat&color=gray&logo=keep-a-changelog)](https://github.com/JunkFood02/Seal/blob/main/CHANGELOG.md) [![GitHub all releases](https://img.shields.io/github/downloads/JunkFood02/Seal/total?label=Downloads&logo=github)](https://github.com/JunkFood02/Seal/releases/) [![GitHub Repo stars](https://img.shields.io/github/stars/JunkFood02/Seal?color=informational&label=Stars)](https://github.com/JunkFood02/Seal/stargazers) [![Supported Sites](https://img.shields.io/badge/Supported-Sites-9cf.svg?style=flat)](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) [![Telegram Channel](https://img.shields.io/badge/Telegram-Seal-blue?style=flat&logo=telegram)](https://t.me/seal_app) [![Matrix Space](https://img.shields.io/badge/Matrix-Seal-Black?style=flat&color=black&logo=matrix)](https://matrix.to/#/#seal-space:matrix.org)
## 屏幕截图
## 功能特色 - 从 [yt-dlp](https://github.com/yt-dlp/yt-dlp) 所支持的数千个视频平台下载视频与音频 - 提取媒体元数据与专辑封面,调用 [mutagen](https://github.com/quodlibet/mutagen) 嵌入到提取的音频文件中 - 播放列表下载支持 - 使用 [aria2c](https://github.com/aria2/aria2) 进行下载 - 内嵌字幕于视频文件中 - 执行自定义的 yt-dlp 命令模板 - 管理应用内下载与自定义命令模板 - 使用简单、用户友好 - 遵循 [Material Design 3](https://m3.material.io/) 设计规范,实现了 [动态色彩](https://m3.material.io/foundations/customization) 主题的应用界面 - MAD:完全使用 Kotlin 构造界面与编写逻辑,单 Activity + Compose Navigation 应用结构 ## 下载 [Get it on F-Droid](https://f-droid.org/packages/com.junkfood.seal/) 你也可以从 [releases](https://github.com/JunkFood02/Seal/releases) 获取最新的 apk ## 贡献 欢迎贡献! 你可以在 [Hosted Weblate](https://hosted.weblate.org/projects/seal/) 参与 Seal 的翻译 [![Translate status](https://hosted.weblate.org/widgets/seal/-/multi-auto.svg)](https://hosted.weblate.org/engage/seal/) 对于错误报告、功能请求或其他改进的想法,请先在 Issue 和 Discussion 中进行搜索(包括已关闭的 Issue)。如果没有出现重复,欢迎 [发起讨论](https://github.com/JunkFood02/Seal/discussions) 或 [提交问题](https://github.com/JunkFood02/Seal/issues/new)。 Seal 被设计为 yt-dlp 的一个简单的 GUI 封装,所以我们不会接受 yt-dlp 不支持的功能请求。 ## 致谢 [youtubedl-android](https://github.com/yausername/youtubedl-android) [yt-dlp](https://github.com/yt-dlp/yt-dlp) [Read You](https://github.com/Ashinch/ReadYou) [Music You](https://github.com/Kyant0/MusicYou) [dvd](https://github.com/yausername/dvd) [Material color utilities](https://github.com/material-foundation/material-color-utilities) ## 许可证 [GNU GPL v3.0](https://github.com/JunkFood02/Seal/blob/main/LICENSE) ================================================ FILE: translations/README-zh_Hant.md ================================================
# Seal ### 只為 Android 而生的視訊/音訊下載程式 English   |    繁體中文 [![F-Droid](https://img.shields.io/f-droid/v/com.junkfood.seal?color=b4eb12&label=F-Droid&logo=fdroid&logoColor=1f78d2)](https://f-droid.org/en/packages/com.junkfood.seal) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/JunkFood02/Seal?color=black&label=Stable&logo=github)](https://github.com/JunkFood02/Seal/releases/latest/) [![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/JunkFood02/Seal?include_prereleases&label=Preview&logo=Github)](https://github.com/JunkFood02/Seal/releases/) [![Keep a Changelog](https://img.shields.io/badge/Changelog-lightgray?style=flat&color=gray&logo=keep-a-changelog)](https://github.com/JunkFood02/Seal/blob/main/CHANGELOG.md) [![GitHub all releases](https://img.shields.io/github/downloads/JunkFood02/Seal/total?label=Downloads&logo=github)](https://github.com/JunkFood02/Seal/releases/) [![GitHub Repo stars](https://img.shields.io/github/stars/JunkFood02/Seal?color=informational&label=Stars)](https://github.com/JunkFood02/Seal/stargazers) [![Supported Sites](https://img.shields.io/badge/Supported-Sites-9cf.svg?style=flat)](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) [![Telegram Channel](https://img.shields.io/badge/Telegram-Seal-blue?style=flat&logo=telegram)](https://t.me/seal_app) [![Matrix Space](https://img.shields.io/badge/Matrix-Seal-Black?style=flat&color=black&logo=matrix)](https://matrix.to/#/#seal-space:matrix.org)
## 📱 螢幕擷圖

## 📖 特色功能 - 透過 [yt-dlp](https://github.com/yt-dlp/yt-dlp) (youtube-dl) 所支援的平臺下載視訊和音訊。 - 透過 [mutagen](https://github.com/quodlibet/mutagen),內嵌中繼資料和視訊縮圖至擷取的音訊檔。 - 一鍵下載播放清單中的全數視訊。 - 支援使用內建之 [aria2c](https://github.com/aria2/aria2) 外部下載程式進行下載。 - 支援內嵌字幕至視訊檔案中。 - 支援執行 yt-dlp 自訂命令範本 - 提供應用程式內管理下載和自訂命令範本。 - 刪繁就簡、簡單易用。 - 使用 [Material Design 3](https://m3.material.io/) UI 風格規範,支援動態顏色主題。 - MAD: 全面使用 Kotlin 建構 UI 和編撰邏輯。單 Activity、無 fragments,僅使用 Compose Destinations。 ## ⬇️ 如何下載 我們建議大多數裝置使用 **arm64-v8a** 版本的 APK 檔。 - 從 [GitHub releases](https://github.com/JunkFood02/Seal/releases/latest) 下載最新穩定發行版 - 安裝 [預先發行版本](https://github.com/JunkFood02/Seal/releases/),以幫助我們測試最新功能和變更 - 穩定發行版亦可於 [F-Droid](https://f-droid.org/packages/com.junkfood.seal/) 取得 ## 💬 如何聯繫 誠摯邀請您加入 [Telegram 頻道](https://t.me/seal_app) 或 [Matrix Space](https://matrix.to/#/#seal-space:matrix.org),與我們一同討論、接收最新消息,以及軟體發行! ## 💖 贊助之友

Gordont1htaJames

Seal 永遠提供眾人免費使用並開放原始碼。若您悅納,請考慮[贊助我](https://github.com/sponsors/JunkFood02)! ## 🤝 一同貢獻 永遠歡迎您的貢獻! 您可以在 [Hosted Weblate](https://hosted.weblate.org/projects/seal/),參與 Seal 的翻譯。 [![Translate status](https://hosted.weblate.org/widgets/seal/-/strings/multi-auto.svg)](https://hosted.weblate.org/engage/seal/) >**備註** > >請在回報錯誤、功能請求、求問,或提供任何改進建言之前,先閱讀 [CONTRIBUTING.md](https://github.com/JunkFood02/Seal/blob/main/CONTRIBUTING.md) 相關指引。 ## ⭐️ Star 歷程 [![Star History Chart](https://api.star-history.com/svg?repos=JunkFood02/Seal&type=Timeline)](https://star-history.com/#JunkFood02/Seal&Timeline) ## 🧱 致謝 Seal 是基於 [youtubedl-android](https://github.com/yausername/youtubedl-android) 的 [yt-dlp](https://github.com/yt-dlp/yt-dlp) 簡易化 GUI。 部分 UI 設計和程式碼來自 [Read You](https://github.com/Ashinch/ReadYou) 和 [Music You](https://github.com/Kyant0/MusicYou)。 [dvd](https://github.com/yausername/dvd) [Material color utilities](https://github.com/material-foundation/material-color-utilities) [Monet](https://github.com/Kyant0/Monet) ## 📃 授權(本節僅供參考,敬請閱讀原文) [![GitHub](https://img.shields.io/github/license/JunkFood02/Seal?style=for-the-badge)](https://github.com/JunkFood02/Seal/blob/main/LICENSE) >**警告** > >除依據 GPLv3 授權之來源代碼外, >各方均不得以 Seal 作為下載程式之應用程式命名, >Seal 衍生產品亦之。 >衍生產品包括但不限於分支和非官方組建。