commit 1563409cb1973de2090d8d3581222e13558b984a Author: Zakaria Date: Mon May 18 14:15:38 2026 -0400 first commit diff --git a/.agents/skills/dart-best-practices/SKILL.md b/.agents/skills/dart-best-practices/SKILL.md new file mode 100644 index 0000000..690cce7 --- /dev/null +++ b/.agents/skills/dart-best-practices/SKILL.md @@ -0,0 +1,65 @@ +--- +name: dart-best-practices +description: |- + General best practices for Dart development. + Covers code style, effective Dart, and language features. +license: Apache-2.0 +--- + +# Dart Best Practices + +## 1. When to use this skill +Use this skill when: +- Writing or reviewing Dart code. +- Looking for guidance on idiomatic Dart usage. + +## 2. Best Practices + +### Multi-line Strings +Prefer using multi-line strings (`'''`) over concatenating strings with `+` and +`\n`, especially for large blocks of text like SQL queries, HTML, or +PEM-encoded keys. This improves readability and avoids +`lines_longer_than_80_chars` lint errors by allowing natural line breaks. + +**Avoid:** +```dart +final pem = '-----BEGIN RSA PRIVATE KEY-----\n' + + base64Encode(fullBytes) + + '\n-----END RSA PRIVATE KEY-----'; +``` + +**Prefer:** +```dart +final pem = ''' +-----BEGIN RSA PRIVATE KEY----- +${base64Encode(fullBytes)} +-----END RSA PRIVATE KEY-----'''; +``` + +### Line Length +Avoid lines longer than 80 characters, even in Markdown files and comments. +This ensures code is readable in split-screen views and on smaller screens +without horizontal scrolling. + +**Prefer:** +Target 80 characters for wrapping text. Exceptions are allowed for long URLs +or identifiers that cannot be broken. + +## Discovery + +### Multi-line Strings +To find candidates for multi-line strings, search for string concatenation +with `+` involving newlines: +- **Regex**: `['"]\s*\+\s*['"]` +- **Regex**: `\+\s*['"].*\\n` + +### Line Length +- Rely on the `lines_longer_than_80_chars` lint from the analyzer. + +## Related Skills + +- **[dart-modern-features]**: For idiomatic + usage of modern Dart features like Pattern Matching (useful for deep JSON + extraction), Records, and Switch Expressions. + +[dart-modern-features]: https://github.com/kevmoo/dash_skills/blob/main/.agent/skills/dart-modern-features/SKILL.md diff --git a/.github/ISSUE_TEMPLATE/1-bug.yml b/.github/ISSUE_TEMPLATE/1-bug.yml new file mode 100644 index 0000000..5f1c171 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-bug.yml @@ -0,0 +1,102 @@ +name: Bug Report +description: Use this template to report bugs, unexpected behavior and accessibility problems. +labels: [bug] +body: + - type: markdown + attributes: + value: | + # Bug Report + Thank you for opening a bug report! Please take the time to properly and thoughtfully fill + out this form to make it easier for us to work with you :) + If you notice you cant fully express your issue in this template, you can always + [open a blank template](https://github.com/flloschy/finamp/issues/new?template=BLANK_ISSUE) + with all the creative freedom you'll need + ⚠️ One Info before you start filling everything out. ⚠️ + If you are *not* using the Beta/Redesign already please only report **critical / app breaking** issues. + This is because the beta version is likely to already have all the features and bug fixes you might be searching for. + + - type: checkboxes + id: updated + attributes: + label: Are you using the newest Version? + description: Did you make sure you are already using the [newest version of Finamp](https://github.com/jmshrv/finamp/releases) and there isn't an issue about this already? + options: + - label: Yes, the issue happens on the newest version + required: true + - label: No, there aren't any open issues about this. I checked! + required: true + + - type: checkboxes + id: type + attributes: + label: Type of Report + description: You can select multiple if applicable + options: + - label: The app is unusable + - label: Something isn't quite right + - label: Some functionality isn't accessible to me + - label: Accessibility + - label: None of the above + + - type: input + id: version + attributes: + label: The exact version of the app + description: You can find the app version inside the settings screen at the top right (the circle with the i) + placeholder: 0.9.18 (beta) + validations: + required: true + + - type: checkboxes + id: affects + attributes: + label: Affected Devices/Platform + options: + - label: Android + - label: Android Auto + - label: iOS + - label: Linux + - label: MacOS (iOS version) + - label: MacOS (native) + - label: Windows + + - type: input + id: device + attributes: + label: Device + description: If you think the device you are using is part of the problem please fill give some details here + + - type: textarea + id: description + attributes: + label: Description & Steps to Reproduce + description: Please describe what the issue precisely is and if possible how you can reproduce it. Additionally a screen-recoding is always welcome! (for legal reasons preferably without audio) + + - type: textarea + id: logs + attributes: + label: Logs + description: | + Please state the approximate/exact time of the issue here + and upload the log file. You may need to put the text file into a + .zip due to file size. + To get the logs Open the side menu, click "Logs", and then use the + *share* button at the top right to get the log file. + You can use the following link to open Finamp's "Logs" screen: [🔗 Logs Screen](https://intradeus.github.io/http-protocol-redirector?r=finamp://internal/logs) + value: | +
+ + Logs + + PLACE YOUR *FULL* LOGS HERE, or attach them as a file + you may censor any private information (although Finamp tries to remove that automatically), just please don't edit out larger sections. + +
+ validations: + required: true + + - type: textarea + id: others + attributes: + label: Additional Information + description: If you want to add something which doesn't fit in the above categories feel free to add this here :) diff --git a/.github/ISSUE_TEMPLATE/2-feature-request.yml b/.github/ISSUE_TEMPLATE/2-feature-request.yml new file mode 100644 index 0000000..4978d1e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-feature-request.yml @@ -0,0 +1,69 @@ +name: Feature Request +description: Use this template to suggest new functionality while keeping it reasonable :) +labels: feature +body: + - type: markdown + attributes: + value: | + # Feature Request + Thank you for opening a feature Request! Please take the time to properly and thoughtfully fill + out this form to make it easier for us to work with you :) + If you notice you cant fully express your feature in this template, you can always + [open a blank template](https://github.com/flloschy/finamp/issues/new?template=BLANK_ISSUE) + with all the creative freedom you'll need + ⚠️ One more Info before you start filling everything out. ⚠️ + If you are *not* using the Beta/Redesign already please do **not** open a feature request. New features + will only be added to the Redesign version of the app + + - type: checkboxes + id: updated + attributes: + label: Are you using the newest Version? + description: Did you make sure you are already using the [newest version of Finamp](https://github.com/jmshrv/finamp/releases) and there isn't an feature request about this already? + options: + - label: Yes, the feature is still missing on the newest version + required: true + - label: No, there aren't any open request about this. I checked! + required: true + + - type: checkboxes + id: type + attributes: + label: Type of Feature + description: You can select multiple if applicable + options: + - label: Something new + - label: Improvement of a feature + - label: None of the above + + - type: textarea + id: description + attributes: + label: Description + description: Please describe in detail what the feature is and how it should act facing the user. + + - type: textarea + id: reason + attributes: + label: Reasoning + description: Please describe why you would like to have this feature and why it benefits Finamp as a whole. + + - type: textarea + id: implementation + attributes: + label: Implementation Notes + description: | + Please give some technical notes like the logic of this feature (when and what should happen and in which order) + If you have more technical knowledge please try to give more information like + - what needs to be done behind the scenes + - potential problems with existing/missing systems + - Packages that might help (from https://pub.dev) + - Packages that may restrict this feature + - Examples how/which other apps are doing this (including screenshots) + - Jellyfin limitations or API endpoints + + - type: textarea + id: others + attributes: + label: Additional Information + description: If you want to add something which doesn't fit in the above categories feel free to add this here :) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..7fdeabb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Got an idea? Open a Discussion! + url: https://github.com/jmshrv/finamp/discussions + about: "If you're not yet sure if something is a good feature request, you can use this to discuss it with us!" + - name: Finamp Beta Testers Discord Server + url: https://discord.gg/xh9SZ73jWk + about: We use Discord for more efficient and casual discussion. This is the best place to get answers quickly! diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..ddd5d31 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,30 @@ + + +## Changes + + +## Todo before merging + + + +- [ ] Translations + +- [ ] Reset Settings + +- [ ] Extended CONTRIBUTING.md + +## Related Issues + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..b12c4ab --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,32 @@ +name: Build + +on: + pull_request: + push: + +jobs: + build-android: + name: Build for Android + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-java@v2 + with: + distribution: 'zulu' + java-version: '17' + - uses: subosito/flutter-action@v2 + with: + channel: 'stable' + - run: flutter pub get + - run: flutter build apk --debug + build-ios: + name: Build for iOS + runs-on: macos-latest + steps: + - uses: actions/checkout@v2 + - uses: subosito/flutter-action@v2 + with: + channel: 'stable' + architecture: x64 + - run: flutter pub get + - run: flutter build ios --release --no-codesign diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f5438a --- /dev/null +++ b/.gitignore @@ -0,0 +1,116 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# Visual Studio Code related +.classpath +.project +.settings/ +.vscode/ + +# packages file containing multi-root paths +.packages.generated + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +**/generated_plugin_registrant.dart +.packages +.pub-cache/ +.pub/ +build/ +flutter_*.png +linked_*.ds +unlinked.ds +unlinked_spec.ds + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java +**/android/key.properties +*.jks + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/.last_build_id +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# macOS +**/macos/Flutter/GeneratedPluginRegistrant.swift +macos/Flutter/ephemeral/flutter_export_environment.sh +macos/Flutter/ephemeral/Flutter-Generated.xcconfig + +# Coverage +coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +!/dev/ci/**/Gemfile.lock + +# Not-yet supported platforms +windows/ +linux/ + diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..ee9d776 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule ".flutter"] + path = .flutter + url = https://github.com/flutter/flutter.git + branch = stable diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..6c3632d --- /dev/null +++ b/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 916c3ac648aa0498a70f32b5fc4f6c51447628e3 + channel: beta + +project_type: app diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c6f7ab2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,60 @@ +# Contributing to Finamp + +Thanks for your interest in contributing to Finamp! This document goes over how to get started on Finamp development, and other ways to contribute. + +## Setting up a Development Environment + +Finamp is a fairly standard Flutter app, so all you have to do is [install Flutter](https://docs.flutter.dev/get-started/install). Once Flutter is installed, you should be able to run Finamp on emulators/real devices. + +### Android Keys + +To build release APKs, you need to set up a signing key for Android. To get that set up, follow [this guide](https://docs.flutter.dev/deployment/android#signing-the-app) from the Flutter documentation. Note that if you have Finamp installed already, your phone may panic because the key is different. + +### The Arcane Arts (Code Generation) + +![A conversation between me and Chaphasilor. I say "did you try running (the Dart build command)?" They reply "I wasn't aware I need to use the arcane arts for this"](assets/arcane-arts.png) + +Because Dart doesn't support macros and stuff, a few dependencies rely on code generation which must be run manually. These dependencies are: + +* Hive - the database that Finamp uses for storing all data +* `json_serializable` - For deserialising JSON into classes +* Chopper - For talking to Jellyfin over HTTP + * This layer (`lib/services/jellyfin_api.dart`) is not used by the app directly. The user-facing API is located at `lib/services/jellyfin_api_helper.dart`. + +To rebuild these files, run `dart run build_runner build --delete-conflicting-outputs`. This must be done when: + +* Modifying a class that is returned by Jellyfin (such as the classes in `lib/models/jellyfin_models.dart`) +* Adding fields to a database class (annotated with `@HiveType`) + +If you don't rebuild generated files, you will encounter: + +* Settings not persisting +* Hive errors on startup +* Missing data when converting JSON to classes + +### Hive + +As said earlier, Finamp uses Hive for all data storage needs. If you're doing work that involves data storage, I recommend you read [the Hive docs](https://docs.hivedb.dev/#/). Please ensure that your changes work when upgrading Finamp from the current release to your changes, as not handling upgrades will cause the app to crash. When downgrading, you will have to wipe your app data if any changes were made to Hive. + +When creating new types, note that you'll also have to register an adapter in `main.dart`. After code generation, there should be a class called `[YourType]Adapter`, which you can initialise in `setupHive`. + +## The Redesign + +The biggest main piece of work being done on Finamp at the moment is the redesign. The relevant issue can be found [here](https://github.com/jmshrv/finamp/issues/220). The `redesign` branch has diverged a lot from `main`, but I try to keep it updated. If you're struggling to decide what to work on, the redesign is a good place to look :) + +## Designing + +As the name implies, we'll need many fresh design mockups for the redesign. Some are already done, some are still being interated on, and some mockups haven't been started yet. + +If you are a designer or have ideas on how the new user interface could/should look, then it would be amazing and really helpfull if you would create a mockup! +You can share your mockups in an issue here on GitHub (use a relevant one if possible, otherwise create a new one) or on Discord for further discussion. +Once the mockup is finalized, people can start implementing it. + +There's also a Figma file with some existing mockups that you can go off of: +Some of the designs in there are already outdated or still a work in progress. When in doubt, you should look at the current design in the actual app, or ask us about it! + +## Translating + +Finamp uses Weblate to manage translations: **https://hosted.weblate.org/engage/finamp/** + +Feel free to add new languages if yours isn't there yet. If you have any questions, such as the context of a string, you can ask in the [Translation Discussions](https://github.com/jmshrv/finamp/discussions/categories/translations). diff --git a/DOWNLOADS_PLAN.md b/DOWNLOADS_PLAN.md new file mode 100644 index 0000000..7d97bce --- /dev/null +++ b/DOWNLOADS_PLAN.md @@ -0,0 +1,37 @@ +# Finamp 1.0 Plan - Downloads Rewrite + +## Introduction + +This document outlines the core plan and motivation for Finamp 1.0. This change will need a major version bump as it will introduce breaking changes. As such, we will have to ensure that users are notified well in advance. + +## Motivations + +Finamp's downloading infrastructure is currently very poor. It was mostly written before I had any formal computer science experience. Because of this, it is very finnicky, and frequently breaks between updates. Some highlights include: + +* Download items being tracked across five key-value databases that all need to be kept in sync: + * `DownloadedItems` - stores downloaded song data. + * `DownloadedParents` - stores data about parents (albums, playlists), with links to children. + * By link, I mean a copy of the Jellyfin metadata stored in `DownloadedItems`. Syncing between the two is done manually. + * `DownloadIds` - a copy of the data stored in `DownloadedItems`, but indexed by the `flutter_downloader` download ID so that we can track the download ID back to the actual song. + * `DownloadedImages` - stores downloaded image data. + * `DownloadedImageIds` - same as `DownloadIds`, but for images. + * `DownloadIds` cannot be used for images, as it takes a `DownloadedSong` data type (yes, a complete copy). +* In the past, I stored absolute paths. The `DownloadedSong` data-type has to account for this, and update as it goes along. + * This is especially problematic on iOS, where the internal app directory changes every update. +* Off the top of my head, there is no clean way to add support for downloaded artists, or especially genres + +The download system also makes use of the [flutter_downloader](https://pub.dev/packages/flutter_downloader) package. In the new system, I will be replacing it with [background_downloader](https://pub.dev/packages/background_downloader) for the following reasons: + +* Desktop support - While downloading isn't 100% essential for desktop, it will be nice to have a complete version of Finamp on desktop. Desktop support will also be required for Linux phones. +* Better looking API - `flutter_downloader` is extremely finnicky, especially around listening for updates. This is why download indicators have never really worked in Finamp. +* Scoped storage support - Finamp's support for external directories is currently practically broken. It seems to be a pretty niche feature, but it would be nice to have working again. +* Relative path handling by default - this will solve the absolute path issue I mentioned earlier. +* Better stability - `flutter_downloader` has had a history of spurious bugs causing crashes and other issues. Most recently, [downloads on iOS failed to get marked as complete](https://github.com/jmshrv/finamp/issues/433), causing downloads to effectively break on iOS. I haven't tried `background_downloader` myself yet, but it has a much more rigid testing setup which should help it catch regressions like this. + +Finamp also uses [Hive](https://pub.dev/packages/hive) for all of its databases. Hive is a great database, but it isn't intended for complex use-cases like this. The same people behind Hive have created [Isar](https://pub.dev/packages/isar), which supports more advanced use-cases. I didn't use this initially as it was still in early development during Finamp's initial development, but it is now stable. For downloads, it will allow me to actually model relations in the database to make adding and removing downloads more reliable. I will also likely replace Finamp's other Hive databases with Isar databases. + +## Impacts + +Doing all of this will be an extremely damaging breaking change. Finamp's last breaking change was [0.5.0](https://github.com/jmshrv/finamp/releases/tag/0.5.0), which required all users to clear their app data (loading databases from before 0.5.0 failed). This was almost acceptable back then - Finamp was still in its early days and hadn't yet been released on the App Store or Play Store (0.5.0 was the first release). Now, Finamp has roughly 25,000 downloads (thank you!). I can't just release an update that breaks everything without giving months of notice, especially since the app will be auto-updated to 1.0. + +While the app won't fail to launch like 0.5.0 did, it will effectively log the user out (as the user database will be replaced). Downloads will also be wiped by deleting the internal song directory. Users must be aware that this is happening - one of the first "actions" of this will be to add a visible warning to the app linking here (with a brief explanation at the top). \ No newline at end of file diff --git a/GitHub_Banner.png b/GitHub_Banner.png new file mode 100644 index 0000000..ed2a35e Binary files /dev/null and b/GitHub_Banner.png differ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a612ad9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..0878db8 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,5 @@ +# Privacy + +Apple made me supply some kind of privacy policy URL, so I'm writing this. + +Finamp does not collect any data. It only talks to the Jellyfin server you set it up with. diff --git a/README.md b/README.md new file mode 100644 index 0000000..098497a --- /dev/null +++ b/README.md @@ -0,0 +1,175 @@ +![Banner](./GitHub_Banner.png) + +## Hacktoberfest + +Ever thought about contributing to Finamp or Open Source in general? +Now is the time! It's Hacktober afterall! + +There are a lot of things **you** can help with with regard to Finamp: + +- Design Improvements + - Help us redesign some missing screens! We have some mockups to get you started, just ask around here on GitHub or on our [Discord Server](https://discord.gg/xh9SZ73jWk)! + - Fix visual bugs or improve the UI +- Bug Hunting + - [Fixing bugs](https://github.com/jmshrv/finamp/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug) + - Adding reproduction steps for existing bugs + - Finding bugs +- Translations + - You can look at our [Weblate Project](https://hosted.weblate.org/engage/finamp/) to add missing translations + - You can also add descriptions (i.e., where that string appears in the app) to some older translations strings in [this file](https://github.com/jmshrv/finamp/blob/redesign/lib/l10n/app_en.arb) +- Improving Documentation + - User Documentation (Was there anything you struggled with at first when using Finamp? How did you solve it?) + - Developer Documentation (Was there anything you struggled with when contribution code to Finamp? Add your solution to [CONTRIBUTING.md](https://github.com/jmshrv/finamp/blob/redesign/CONTRIBUTING.md)) +- Improving the codebase + - Optimizations (eg. Finamp currently consumes a lot of battery) + - Documentation + - Clean up +- Support + - Help other people on our [Discord Server](https://discord.gg/xh9SZ73jWk) + - Give feedback on [Pull Requests](https://github.com/jmshrv/finamp/pulls) +- Spread the word and get your friends to contribute :D + +If you like Finamp but don't want to contribute for any reason, you can also contribute [to Jellyfin directly](https://github.com/jellyfin/jellyfin) or to upstream libraries which Finamp uses ([`just_audio`](https://github.com/ryanheise/just_audio), [`background_downloader`](https://github.com/781flyingdutchman/background_downloader/), etc.). +That way you can help Finamp indirectly and Open Source as a whole! +Finamp can't exists without maintained and stable libraries :) + +### What to do? + +There's still a lot left over from our latest "Finamplify" Hackathon! +Take a look at the [Finamplify Project Board](https://github.com/users/jmshrv/projects/5) or even the full [Redesign Project Board](https://github.com/users/jmshrv/projects/2) (the latter is slightly outdated). +You can find an overview of (hopefully) easy to tackle issues [here on GitHub](https://github.com/jmshrv/finamp/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22easy%20fix%22%20no%3Aassignee). For some of those prior programming experience is definitely helpful. + +Specifically, here a short list of long awaited features: + +- Car Play +- Metadata editing +- Multi user/server support +- Various improvements for Finamp Desktop (Fixing shuffle, improved UI, better system integration) +- Automatic (Widget) Tests + +### How to get started + +>[!Important] +> **Make sure to check out the `redesign` branch (`git checkout redesign`)! This is where all development happens at the moment!** + +Start by reading the ["Setting up a Development Environment" section](https://github.com/jmshrv/finamp/blob/redesign/CONTRIBUTING.md#setting-up-a-development-environment) in our contribution guidelines. +Then, once flutter is working, you can simply do `flutter run`! +Any changes you do to the code can be applied via hot-reload by pressing `r` in the terminal. There are also first-party Flute integrations for many editors and IDEs. + +If you have any questions, just reach out to us on GitHub or [Discord](https://discord.gg/xh9SZ73jWk)! + +--- + +## Redesign Beta + +We're currently in the process of redesigning Finamp to transform it into a modern, beautiful, and feature-rich music player made specifically for Jellyfin. +You can join the beta on [Google Play](https://play.google.com/store/apps/details?id=com.unicornsonlsd.finamp) and [Apple TestFlight](https://testflight.apple.com/join/UqHTQTSs), or download the latest beta APK from the [releases page](https://github.com/jmshrv/finamp/releases). +Please note that the beta is still work-in-progress, so the UI and functionality might be inconsistent or incomplete, and is not final. However, the beta is **fully functional and should be stable** enough for daily use. + +--- + +**Finamp** is a Jellyfin music player for Android and iOS. It's meant to give you a similar listening experience as traditional streaming services such as Spotify and Apple Music, but for the music that you already own. It's free, open-source software, just like Jellyfin itself. +Some of its features include: + +- A welcoming user interface that looks modern & unique, but still familiar +- Downloading files for offline listening and saving mobile data. Can use transcoded downloads to save even more space. +- Transcoded streaming for saving mobile data +- Beautiful dynamic colors that adapt to your media +- Audio volume normalization ("ReplayGain") (Jellyfin 10.9+) +- Lyrics (Jellyfin 10.9+) +- Gapless playback +- Android Auto support (coming soon™) +- Full support for Jellyfin's "Playback Reporting" feature and plugin, letting you keep track of your listening activity +- Integration with [AudioMuse](https://github.com/NeptuneHub/AudioMuse-AI) for sonic analysis and improved mixes + +***You need your own Jellyfin server to use Finamp. If you don't have one yet, take a look at [Jellyfin's website](https://jellyfin.org/) to learn more about it and how to set it up.*** + +## Getting Finamp + +
+ +[Get it on F-Droid](https://f-droid.org/packages/com.unicornsonlsd.finamp/) + +[Get it on Google Play](https://play.google.com/store/apps/details?id=com.unicornsonlsd.finamp) + +[Download on the App Store](https://apps.apple.com/us/app/finamp/id1574922594) + +
+ +Note: The F-Droid release may take a day or two to get updates because [F-Droid only builds once a day](https://www.f-droid.org/en/docs/FAQ_-_App_Developers/#ive-published-a-new-release-why-is-it-not-in-the-repository). +The app is also available as an APK from the [releases page](https://github.com/jmshrv/finamp/releases). + +The SHA-256 fingerprint of Finamp's signing certificate is `20:61:C5:C9:28:9C:00:02:08:81:B7:E5:33:4D:93:A0:2D:FA:4B:E9:80:AF:20:C0:5D:B4:E5:29:C8:DA:5B:54`. Google Play releases and provided APKs will be signed with this certificate. F-Droid releases will be signed with F-Droid's own signing certificates. + +### Frequently Asked Questions + +#### Before Installing + +##### Is Finamp free? + +Absolutely! It costs nothing to use. We do appreciate voluntary contributions of any kind though, be that bug reports, code, designs, or ideas for new features. You can also donate to some of the developers to show your appreciation <3 + +##### How can I install Finamp? + +On Android, Finamp can be installed from the Google Play Store, F-Droid store, or directly by installing the APK file from GitHub. +On iOS, you can install Finamp through Apple's App Store. Just click on the buttons above. + +##### Does Finamp support my media formats? + +Finamp should support all formats supported by Jellyfin. Some more advanced formats could cause issues for regular playback, but transcoding should fix these issues. + +##### Does Finamp support Android Auto / Apple CarPlay? + +Theoretically, but not yet. There is [an issue for this](https://github.com/jmshrv/finamp/issues/24) that contains a proof of concept for Android Auto in there, but it hasn't been tested yet. Maybe you could help out! + +##### Is Finamp legal? + +Yes. Finamp is a *tool* that lets you interface with a Jellyfin server. Finamp does not come with any music, and will not connect to streaming services other than Jellyfin. You will need to bring your own media and add it to Jellyfin, for example by purchasing music online. This often also directly supports your favorite artists! + +#### After Installing + +##### I'm having trouble with Finamp, where can I find help? + +If you're experiencing software bugs or other issues with Finamp, be sure to take a look at [Finamp's issue tracker](https://github.com/jmshrv/finamp/issues), especially the pinned issues at the top of the page. If you can't find anything related to your specific problem, please create a new issue (you will need a GitHub account). + +## Contributing + +Finamp is a community-driven project and relies on people like **you** and their contributions. To learn how you could help out with making Finamp even better, take a look at our [Contribution Guidelines](CONTRIBUTING.md) + +### Translations + +You can also contribute by helping to translate Finamp! This is done through our Weblate instance here: . The current translation status is this: + + + Translation status + + +## Known Issues + +This app is still a work in progress, and has some bugs/issues that haven't been fixed yet. Here is a list of currently known issues: + +- Reordering the queue while shuffle is enabled is not possible at the moment. It seems like this is an issue with a dependency of Finamp (`just_audio`), and is being tracked [here](https://github.com/ryanheise/just_audio/issues/1042) +- If you have a very large library or an older phone, performance might not be great in some places + +## Planned Features + +- Improved Android Auto / Apple CarPlay support +- Full redesign, adding more features and a home screen. See [this issue](https://github.com/jmshrv/finamp/issues/220) for more info +- Better playlist editing +- Multiple users/servers +- More customization options + +## Screenshots (Stable Version, outdated) + +| | | +|:-------------------------:|:-------------------------:| +|> | > +| > | > | + +Name source: diff --git a/Showcase.png b/Showcase.png new file mode 100644 index 0000000..ba59319 Binary files /dev/null and b/Showcase.png differ diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..a3be6b8 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml \ No newline at end of file diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..5f55776 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,77 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('key.properties') +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + +android { + compileSdkVersion 34 + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + lintOptions { + disable 'InvalidPackage' + } + + defaultConfig { + applicationId "com.unicornsonlsd.finamp" + minSdkVersion 21 + targetSdkVersion 34 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + signingConfigs { + release { + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null + storePassword keystoreProperties['storePassword'] + } + } + buildTypes { + release { + signingConfig signingConfigs.release + // shrinkResources false + // minifyEnabled false + } + debug { + applicationIdSuffix '.debug' + minifyEnabled true // avoid dexing issues + } + } +} + +flutter { + source '../..' +} + +dependencies { + implementation 'androidx.appcompat:appcompat:1.3.1' + implementation 'androidx.mediarouter:mediarouter:1.7.0' +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..276a25e --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..bb1b92d --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/unicornsonlsd/finamp/MainActivity.kt b/android/app/src/main/kotlin/com/unicornsonlsd/finamp/MainActivity.kt new file mode 100644 index 0000000..a66d6cb --- /dev/null +++ b/android/app/src/main/kotlin/com/unicornsonlsd/finamp/MainActivity.kt @@ -0,0 +1,6 @@ +package com.unicornsonlsd.finamp + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() { +} diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..bcf2d64 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..61ae01a Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..616eb7f Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..af38072 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..46ba4f6 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..19dc1a3 --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..5f349f7 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..324e056 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/white.png b/android/app/src/main/res/mipmap-hdpi/white.png new file mode 100644 index 0000000..d13130f Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/white.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..71dc2fe Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/white.png b/android/app/src/main/res/mipmap-mdpi/white.png new file mode 100644 index 0000000..8f5d641 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/white.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..0ecee89 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/white.png b/android/app/src/main/res/mipmap-xhdpi/white.png new file mode 100644 index 0000000..a82b2b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/white.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..71f3457 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/white.png b/android/app/src/main/res/mipmap-xxhdpi/white.png new file mode 100644 index 0000000..7c7b6c7 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/white.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..ded315b Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/white.png b/android/app/src/main/res/mipmap-xxxhdpi/white.png new file mode 100644 index 0000000..742f263 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/white.png differ diff --git a/android/app/src/main/res/raw/keep.xml b/android/app/src/main/res/raw/keep.xml new file mode 100644 index 0000000..0a4bfd5 --- /dev/null +++ b/android/app/src/main/res/raw/keep.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..90a721e --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #000B25 + \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..a9348fe --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..276a25e --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..65722a4 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,29 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + afterEvaluate { project -> + if (project.plugins.hasPlugin("com.android.application") || + project.plugins.hasPlugin("com.android.library")) { + project.android { + compileSdkVersion 34 + buildToolsVersion "34.0.0" + } + } + } +} +subprojects { + project.evaluationDependsOn(':app') +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..38c8d45 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx1536M +android.enableR8=true +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..dcf0f19 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..229de2e --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,26 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + } + settings.ext.flutterSdkPath = flutterSdkPath() + + includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "7.4.0" apply false + id "org.jetbrains.kotlin.android" version "1.9.10" apply false +} + +include ":app" diff --git a/android/settings_aar.gradle b/android/settings_aar.gradle new file mode 100644 index 0000000..e7b4def --- /dev/null +++ b/android/settings_aar.gradle @@ -0,0 +1 @@ +include ':app' diff --git a/app-store-badges/app-store.svg b/app-store-badges/app-store.svg new file mode 100755 index 0000000..072b425 --- /dev/null +++ b/app-store-badges/app-store.svg @@ -0,0 +1,46 @@ + + Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app-store-badges/fdroid.png b/app-store-badges/fdroid.png new file mode 100644 index 0000000..afa603c Binary files /dev/null and b/app-store-badges/fdroid.png differ diff --git a/app-store-badges/play-store.png b/app-store-badges/play-store.png new file mode 100644 index 0000000..c77b746 Binary files /dev/null and b/app-store-badges/play-store.png differ diff --git a/assets/arcane-arts.png b/assets/arcane-arts.png new file mode 100644 index 0000000..36c2ae1 Binary files /dev/null and b/assets/arcane-arts.png differ diff --git a/assets/icon/icon_combined.png b/assets/icon/icon_combined.png new file mode 100644 index 0000000..99b236a Binary files /dev/null and b/assets/icon/icon_combined.png differ diff --git a/assets/icon/icon_foreground.png b/assets/icon/icon_foreground.png new file mode 100644 index 0000000..369bf77 Binary files /dev/null and b/assets/icon/icon_foreground.png differ diff --git a/assets/icon/icon_foreground.svg b/assets/icon/icon_foreground.svg new file mode 100644 index 0000000..7ee10e8 --- /dev/null +++ b/assets/icon/icon_foreground.svg @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icon/icon_foreground_noborder.svg b/assets/icon/icon_foreground_noborder.svg new file mode 100644 index 0000000..1ebcf2b --- /dev/null +++ b/assets/icon/icon_foreground_noborder.svg @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icon/icon_white_noborder.png b/assets/icon/icon_white_noborder.png new file mode 100644 index 0000000..ffc1776 Binary files /dev/null and b/assets/icon/icon_white_noborder.png differ diff --git a/assets/icon/icon_white_noborder.svg b/assets/icon/icon_white_noborder.svg new file mode 100644 index 0000000..3b95bb2 --- /dev/null +++ b/assets/icon/icon_white_noborder.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + diff --git a/fastlane/metadata/android/en-US/changelogs/10.txt b/fastlane/metadata/android/en-US/changelogs/10.txt new file mode 100644 index 0000000..495c18a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/10.txt @@ -0,0 +1,3 @@ +This release adds a new storage selector and other minor improvements. This new selector can pick the app's external storage directories, which should get around Android permission issues. + +Full changelog at https://github.com/UnicornsOnLSD/finamp/releases/tag/0.4.3 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/14.txt b/fastlane/metadata/android/en-US/changelogs/14.txt new file mode 100644 index 0000000..dd41465 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/14.txt @@ -0,0 +1,3 @@ +This release contains many new features, but also breaking changes. You'll have to clear your app data. + +Full changelog at https://github.com/UnicornsOnLSD/finamp/releases/tag/0.5.0 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/15.txt b/fastlane/metadata/android/en-US/changelogs/15.txt new file mode 100644 index 0000000..35fefdb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/15.txt @@ -0,0 +1 @@ +Bug fix release. Full changelog at https://github.com/UnicornsOnLSD/finamp/releases/tag/0.5.1 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/16.txt b/fastlane/metadata/android/en-US/changelogs/16.txt new file mode 100644 index 0000000..be2c4d7 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/16.txt @@ -0,0 +1 @@ +This release adds way to much to talk about here. Release notes at https://github.com/UnicornsOnLSD/finamp/releases/tag/0.6.0 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/17.txt b/fastlane/metadata/android/en-US/changelogs/17.txt new file mode 100644 index 0000000..98b9144 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/17.txt @@ -0,0 +1 @@ +Bug fix release. Full changelog at https://github.com/UnicornsOnLSD/finamp/releases/tag/0.6.1 diff --git a/fastlane/metadata/android/en-US/changelogs/18.txt b/fastlane/metadata/android/en-US/changelogs/18.txt new file mode 100644 index 0000000..45cbe5a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/18.txt @@ -0,0 +1 @@ +Bug fix release. Full changelog at https://github.com/jmshrv/finamp/releases/tag/0.6.2 diff --git a/fastlane/metadata/android/en-US/changelogs/19.txt b/fastlane/metadata/android/en-US/changelogs/19.txt new file mode 100644 index 0000000..5512217 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/19.txt @@ -0,0 +1 @@ +Bug fix/a few features release. Full changelog at https://github.com/UnicornsOnLSD/finamp/releases/tag/0.6.3 diff --git a/fastlane/metadata/android/en-US/changelogs/20.txt b/fastlane/metadata/android/en-US/changelogs/20.txt new file mode 100644 index 0000000..6d8cb9c --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/20.txt @@ -0,0 +1 @@ +Minor feature release. Full changelog at https://github.com/UnicornsOnLSD/finamp/releases/tag/0.6.4 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/21.txt b/fastlane/metadata/android/en-US/changelogs/21.txt new file mode 100644 index 0000000..d141c8f --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/21.txt @@ -0,0 +1 @@ +Minor feature release. Full changelog at https://github.com/UnicornsOnLSD/finamp/releases/tag/0.6.5 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/23.txt b/fastlane/metadata/android/en-US/changelogs/23.txt new file mode 100644 index 0000000..7f1579d --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/23.txt @@ -0,0 +1 @@ +Minor feature release. Full changelog at https://github.com/UnicornsOnLSD/finamp/releases/tag/0.6.6 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/24.txt b/fastlane/metadata/android/en-US/changelogs/24.txt new file mode 100644 index 0000000..70098f0 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/24.txt @@ -0,0 +1 @@ +Bug fix release. Full changelog at https://github.com/UnicornsOnLSD/finamp/releases/tag/0.6.7 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/25.txt b/fastlane/metadata/android/en-US/changelogs/25.txt new file mode 100644 index 0000000..9f05ed6 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/25.txt @@ -0,0 +1 @@ +Bug fix release. Full changelog at https://github.com/jmshrv/finamp/releases/tag/0.6.8 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/26.txt b/fastlane/metadata/android/en-US/changelogs/26.txt new file mode 100644 index 0000000..ea1f289 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/26.txt @@ -0,0 +1 @@ +(not very) minor feature release. Full changelog at https://github.com/jmshrv/finamp/releases/tag/0.6.9 diff --git a/fastlane/metadata/android/en-US/changelogs/29.txt b/fastlane/metadata/android/en-US/changelogs/29.txt new file mode 100644 index 0000000..e98083a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/29.txt @@ -0,0 +1 @@ +Lots of bug fixes. Full changelog at https://github.com/jmshrv/finamp/releases/tag/0.6.11 diff --git a/fastlane/metadata/android/en-US/changelogs/30.txt b/fastlane/metadata/android/en-US/changelogs/30.txt new file mode 100644 index 0000000..370f4d1 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/30.txt @@ -0,0 +1 @@ +Fixed issue that broke downloading from HTTP servers \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/31.txt b/fastlane/metadata/android/en-US/changelogs/31.txt new file mode 100644 index 0000000..d6c13b3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/31.txt @@ -0,0 +1 @@ +Downloader fixes, and a language selector. Full changelog at https://github.com/jmshrv/finamp/releases/tag/0.6.13 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/32.txt b/fastlane/metadata/android/en-US/changelogs/32.txt new file mode 100644 index 0000000..84057a7 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/32.txt @@ -0,0 +1 @@ +Downloader fixes, and a language selector. Full changelog at https://github.com/jmshrv/finamp/releases/tag/0.6.14 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/36.txt b/fastlane/metadata/android/en-US/changelogs/36.txt new file mode 100644 index 0000000..792b37e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/36.txt @@ -0,0 +1 @@ +New features and bug fixes. Full changelog at https://github.com/jmshrv/finamp/releases/tag/0.6.15 diff --git a/fastlane/metadata/android/en-US/changelogs/37.txt b/fastlane/metadata/android/en-US/changelogs/37.txt new file mode 100644 index 0000000..2ad6c7e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/37.txt @@ -0,0 +1 @@ +Fixed a bug created in 0.6.15. Full changelog at https://github.com/jmshrv/finamp/releases/tag/0.6.16 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/38.txt b/fastlane/metadata/android/en-US/changelogs/38.txt new file mode 100644 index 0000000..8a19668 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/38.txt @@ -0,0 +1 @@ +Fix F-Droid build. Changes from 0.6.16 (which was missed) can be seen at https://github.com/jmshrv/finamp/releases/tag/0.6.16 diff --git a/fastlane/metadata/android/en-US/changelogs/39.txt b/fastlane/metadata/android/en-US/changelogs/39.txt new file mode 100644 index 0000000..7bd9b78 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/39.txt @@ -0,0 +1 @@ +Quality of life and bug fixes. Full changelog at https://github.com/jmshrv/finamp/releases/tag/0.6.18 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/41.txt b/fastlane/metadata/android/en-US/changelogs/41.txt new file mode 100644 index 0000000..8f71747 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/41.txt @@ -0,0 +1 @@ +New features and bug fixes. Full changelog at https://github.com/jmshrv/finamp/releases/tag/0.6.20 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/43.txt b/fastlane/metadata/android/en-US/changelogs/43.txt new file mode 100644 index 0000000..6cab8bd --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/43.txt @@ -0,0 +1 @@ +New features and bug fixes. Full changelog at https://github.com/jmshrv/finamp/releases/tag/0.6.21 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/44.txt b/fastlane/metadata/android/en-US/changelogs/44.txt new file mode 100644 index 0000000..50f0de5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/44.txt @@ -0,0 +1 @@ +Fixed a bug where the artist screen would show albums from the whole library instead of the specific artist. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/45.txt b/fastlane/metadata/android/en-US/changelogs/45.txt new file mode 100644 index 0000000..50f0de5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/45.txt @@ -0,0 +1 @@ +Fixed a bug where the artist screen would show albums from the whole library instead of the specific artist. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/46.txt b/fastlane/metadata/android/en-US/changelogs/46.txt new file mode 100644 index 0000000..8a6a0fd --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/46.txt @@ -0,0 +1 @@ +Reverted to Android API 33 since 34 seems to break background playback diff --git a/fastlane/metadata/android/en-US/changelogs/47.txt b/fastlane/metadata/android/en-US/changelogs/47.txt new file mode 100644 index 0000000..16d5294 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/47.txt @@ -0,0 +1 @@ +Updated translations, rebuilt with a newer version of Flutter. Changes are now only happening on the redesign beta, check it out on GitHub! diff --git a/fastlane/metadata/android/en-US/changelogs/49.txt b/fastlane/metadata/android/en-US/changelogs/49.txt new file mode 100644 index 0000000..312d95a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/49.txt @@ -0,0 +1 @@ +Updated dependencies, fixed F-Droid build. Changes are now only happening on the redesign beta, check it out on GitHub! diff --git a/fastlane/metadata/android/en-US/changelogs/51.txt b/fastlane/metadata/android/en-US/changelogs/51.txt new file mode 100644 index 0000000..f299326 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/51.txt @@ -0,0 +1 @@ +Fixed background playback. Changes are now only happening on the redesign beta, check it out on GitHub! diff --git a/fastlane/metadata/android/en-US/changelogs/6.txt b/fastlane/metadata/android/en-US/changelogs/6.txt new file mode 100644 index 0000000..b6e88ac --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/6.txt @@ -0,0 +1 @@ +This release just adds required metadata for the F-Droid release. diff --git a/fastlane/metadata/android/en-US/changelogs/7.txt b/fastlane/metadata/android/en-US/changelogs/7.txt new file mode 100644 index 0000000..1a10892 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/7.txt @@ -0,0 +1,3 @@ +This release adds a lot of stuff, but the main thing is that you can now choose where to store downloads. Download locations can be configured in the settings screen. You can pick any directory that your phone would usually have access to, including SD cards. On iOS, you can store files in Files. Deleting songs manually (via your file manager) shouldn't break anything, but I wouldn't recommend it anyway. + +Full changelog at https://github.com/UnicornsOnLSD/finamp/releases/tag/0.4.0 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/8.txt b/fastlane/metadata/android/en-US/changelogs/8.txt new file mode 100644 index 0000000..d5b1ce9 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/8.txt @@ -0,0 +1 @@ +This release fixes a bug where viewing an artist's albums will show a grey screen if any of the albums didn't have a release year. Artist's albums are now sorted by the server, like with everything else. I've also added some stuff so that unknown years don't show up as "null". \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/9.txt b/fastlane/metadata/android/en-US/changelogs/9.txt new file mode 100644 index 0000000..5648949 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/9.txt @@ -0,0 +1 @@ +This hotfix fixes all album tab views being grey, which was caused by the fix in the last hotfix. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..1c84aae --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,22 @@ +Finamp is a Jellyfin music player for Android and iOS. Its main feature is the ability to download songs for offline listening. + +Translations + +Finamp uses Weblate to manage translations: https://hosted.weblate.org/engage/finamp/ + +Feel free to add new languages if yours isn't there yet. If you have any questions, such as the context of a string, you can ask in the Translation Discussions (https://github.com/jmshrv/finamp/discussions/categories/translations). + +Known Issues + +This app is still a work in progress, and has some bugs/issues that haven't been fixed yet. Here is a list of currently known issues: + +
  • Deleting large items (such as playlists) will cause the app to freeze for a few seconds
  • +
  • Download indicators occasionally don't update
  • + +Planned Features + +
  • Transcoding support for downloads
  • +
  • Multiple users/servers
  • +
  • Translation support
  • + +Name source: https://www.reddit.com/r/jellyfin/comments/hjxshn/jellyamp_crossplatform_desktop_music_player/fwqs5i0/ diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 0000000..bb48a0e Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png new file mode 100644 index 0000000..926b530 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png new file mode 100644 index 0000000..43d4f57 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png new file mode 100644 index 0000000..1ff98f7 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png new file mode 100644 index 0000000..09eeb69 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000..3e96932 --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Finamp is a music player for Jellyfin. diff --git a/flutter_launcher_icons-white-notification.yaml b/flutter_launcher_icons-white-notification.yaml new file mode 100644 index 0000000..58c01e1 --- /dev/null +++ b/flutter_launcher_icons-white-notification.yaml @@ -0,0 +1,4 @@ +flutter_icons: + android: true + ios: false + image_path: "assets/icon/icon_white_noborder.png" diff --git a/flutterw b/flutterw new file mode 100755 index 0000000..4193021 --- /dev/null +++ b/flutterw @@ -0,0 +1,113 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Flutter start up script for UN*X +## Version: v1.3.1 +## Date: 2023-04-12 16:58:43 +## +## Use this flutter wrapper to bundle Flutter within your project to make +## sure everybody builds with the same version. +## +## Read about the install and uninstall process in the README on GitHub +## https://github.com/passsy/flutter_wrapper +## +## Inspired by gradle-wrapper. +## +############################################################################## + +echoerr() { echo "$@" 1>&2; } + +# 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 + +FLUTTER_SUBMODULE_NAME='.flutter' +GIT_HOME=$(git -C "${APP_HOME}" rev-parse --show-toplevel) +FLUTTER_DIR="${GIT_HOME}/${FLUTTER_SUBMODULE_NAME}" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +# Fix not initialized flutter submodule +if [ ! -f "${FLUTTER_DIR}/bin/flutter" ]; then + echoerr "$FLUTTER_SUBMODULE_NAME submodule not initialized. Initializing..." + git submodule update --init "${FLUTTER_DIR}" +fi + +# Detect detach HEAD and fix it. commands like upgrade expect a valid branch, not a detached HEAD +FLUTTER_SYMBOLIC_REF=$(git -C "${FLUTTER_DIR}" symbolic-ref -q HEAD) +if [ -z "${FLUTTER_SYMBOLIC_REF}" ]; then + FLUTTER_REV=$(git -C "${FLUTTER_DIR}" rev-parse HEAD) + FLUTTER_CHANNEL=$(git -C "${GIT_HOME}" config -f .gitmodules submodule.${FLUTTER_SUBMODULE_NAME}.branch) + + if [ -z "${FLUTTER_CHANNEL}" ]; then + echoerr "Warning: Submodule '$FLUTTER_SUBMODULE_NAME' doesn't point to an official Flutter channel \ +(one of stable|beta|dev|master). './flutterw upgrade' will fail without a channel." + echoerr "Fix this by adding a specific channel with:" + echoerr " - './flutterw channel ' or" + echoerr " - Add 'branch = ' to '$FLUTTER_SUBMODULE_NAME' submodule in .gitmodules" + else + echoerr "Fixing detached HEAD: '$FLUTTER_SUBMODULE_NAME' submodule points to a specific commit $FLUTTER_REV, not channel '$FLUTTER_CHANNEL' (as defined in .gitmodules)." + # Make sure channel is fetched + # Remove old channel branch because it might be moved to an unrelated commit where fast-forward pull isn't possible + git -C "${FLUTTER_DIR}" branch -q -D "${FLUTTER_CHANNEL}" 2> /dev/null || true + git -C "${FLUTTER_DIR}" fetch -q origin + + # bind current HEAD to channel defined in .gitmodules + git -C "${FLUTTER_DIR}" checkout -q -b "${FLUTTER_CHANNEL}" "${FLUTTER_REV}" + git -C "${FLUTTER_DIR}" branch -q -u "origin/${FLUTTER_CHANNEL}" "${FLUTTER_CHANNEL}" + echoerr "Fixed! Migrated to channel '$FLUTTER_CHANNEL' while staying at commit $FLUTTER_REV. './flutterw upgrade' now works without problems!" + git -C "${FLUTTER_DIR}" status -bs + fi +fi + +# Wrapper tasks done, call flutter binary with all args +set -e +"$FLUTTER_DIR/bin/flutter" "$@" +set +e + +# Post flutterw tasks. exit code from /bin/flutterw will be used as final exit +FLUTTER_EXIT_STATUS=$? +if [ ${FLUTTER_EXIT_STATUS} -eq 0 ]; then + + # ./flutterw channel CHANNEL + if echo "$@" | grep -q "channel"; then + if [ -n "$2" ]; then + # make sure .gitmodules is updated as well + CHANNEL=${2} # second arg + git config -f "${GIT_HOME}/.gitmodules" "submodule.${FLUTTER_SUBMODULE_NAME}.branch" "${CHANNEL}" + # makes sure nobody forgets to do commit all changed files + git add "${GIT_HOME}/.gitmodules" + git add "${FLUTTER_DIR}" + fi + fi + + # ./flutterw upgrade + if echo "$@" | grep -q "upgrade"; then + # makes sure nobody forgets to do commit the changed submodule + git add "${FLUTTER_DIR}" + # flutter packages get runs automatically. Stage those changes as well + if [ -f pubspec.lock ]; then + git add pubspec.lock + fi + fi +fi + +exit ${FLUTTER_EXIT_STATUS} diff --git a/icon-commands.txt b/icon-commands.txt new file mode 100644 index 0000000..05a4ae7 --- /dev/null +++ b/icon-commands.txt @@ -0,0 +1,5 @@ +Foreground (Android) +convert -trim -gravity center -background none -resize 2160x2160 -extent 4320x4320 icon_foreground.svg icon_foreground.png + +Combined (iOS) +convert -trim -gravity center -background "#000B25" -resize 3240x3240 -extent 4320x4320 icon_foreground.svg icon_combined.png diff --git a/images/finamp.png b/images/finamp.png new file mode 100644 index 0000000..46ba4f6 Binary files /dev/null and b/images/finamp.png differ diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..cae6543 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..e8efba1 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..399e934 --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..2736914 --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,97 @@ +# Uncomment this line to define a global platform for your project +platform :ios, '12.0' + +# Fixing DKImagePickerController Bug +use_modular_headers! +pod 'DKImagePickerController', '4.3.4' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '11.0' + end + + target.build_configurations.each do |config| + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ + '$(inherited)', + + ## dart: PermissionGroup.calendar + 'PERMISSION_EVENTS=0', + + ## dart: PermissionGroup.reminders + 'PERMISSION_REMINDERS=0', + + ## dart: PermissionGroup.contacts + 'PERMISSION_CONTACTS=0', + + ## dart: PermissionGroup.camera + 'PERMISSION_CAMERA=0', + + ## dart: PermissionGroup.microphone + 'PERMISSION_MICROPHONE=0', + + ## dart: PermissionGroup.speech + 'PERMISSION_SPEECH_RECOGNIZER=0', + + ## dart: PermissionGroup.photos + 'PERMISSION_PHOTOS=0', + + ## dart: [PermissionGroup.location, PermissionGroup.locationAlways, PermissionGroup.locationWhenInUse] + 'PERMISSION_LOCATION=0', + + ## dart: PermissionGroup.notification + 'PERMISSION_NOTIFICATIONS=0', + + ## dart: PermissionGroup.mediaLibrary + 'PERMISSION_MEDIA_LIBRARY=1', + + ## dart: PermissionGroup.sensors + 'PERMISSION_SENSORS=0', + + ## dart: PermissionGroup.bluetooth + 'PERMISSION_BLUETOOTH=0', + + ## dart: PermissionGroup.appTrackingTransparency + 'PERMISSION_APP_TRACKING_TRANSPARENCY=0', + + 'AUDIO_SESSION_MICROPHONE=0' + ] + + end + end +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..61b6c6f --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,161 @@ +PODS: + - audio_service (0.0.1): + - Flutter + - audio_session (0.0.1): + - Flutter + - CropViewController (2.6.1) + - device_info_plus (0.0.1): + - Flutter + - DKCamera (1.6.7) + - DKImagePickerController (4.3.4): + - DKImagePickerController/Camera (= 4.3.4) + - DKImagePickerController/Core (= 4.3.4) + - DKImagePickerController/ImageDataManager (= 4.3.4) + - DKImagePickerController/InlineCamera (= 4.3.4) + - DKImagePickerController/PhotoEditor (= 4.3.4) + - DKImagePickerController/PhotoGallery (= 4.3.4) + - DKImagePickerController/Resource (= 4.3.4) + - DKImagePickerController/Camera (4.3.4): + - DKCamera + - DKImagePickerController/Core + - DKImagePickerController/Core (4.3.4): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.4) + - DKImagePickerController/InlineCamera (4.3.4): + - DKCamera + - DKImagePickerController/Core + - DKImagePickerController/PhotoEditor (4.3.4): + - CropViewController (~> 2.5) + - DKImagePickerController/Core + - DKImagePickerController/PhotoGallery (4.3.4): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.4) + - DKPhotoGallery (0.0.17): + - DKPhotoGallery/Core (= 0.0.17) + - DKPhotoGallery/Model (= 0.0.17) + - DKPhotoGallery/Preview (= 0.0.17) + - DKPhotoGallery/Resource (= 0.0.17) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.17): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.17): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.17): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.17): + - SDWebImage + - SwiftyGif + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter + - Flutter (1.0.0) + - flutter_downloader (0.0.1): + - Flutter + - just_audio (0.0.1): + - Flutter + - package_info_plus (0.4.5): + - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - permission_handler_apple (9.3.0): + - Flutter + - SDWebImage (5.13.4): + - SDWebImage/Core (= 5.13.4) + - SDWebImage/Core (5.13.4) + - share_plus (0.0.1): + - Flutter + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - SwiftyGif (5.4.3) + - url_launcher_ios (0.0.1): + - Flutter + +DEPENDENCIES: + - audio_service (from `.symlinks/plugins/audio_service/ios`) + - audio_session (from `.symlinks/plugins/audio_session/ios`) + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) + - DKImagePickerController (= 4.3.4) + - file_picker (from `.symlinks/plugins/file_picker/ios`) + - Flutter (from `Flutter`) + - flutter_downloader (from `.symlinks/plugins/flutter_downloader/ios`) + - just_audio (from `.symlinks/plugins/just_audio/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + +SPEC REPOS: + trunk: + - CropViewController + - DKCamera + - DKImagePickerController + - DKPhotoGallery + - SDWebImage + - SwiftyGif + +EXTERNAL SOURCES: + audio_service: + :path: ".symlinks/plugins/audio_service/ios" + audio_session: + :path: ".symlinks/plugins/audio_session/ios" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" + file_picker: + :path: ".symlinks/plugins/file_picker/ios" + Flutter: + :path: Flutter + flutter_downloader: + :path: ".symlinks/plugins/flutter_downloader/ios" + just_audio: + :path: ".symlinks/plugins/just_audio/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + +SPEC CHECKSUMS: + audio_service: f509d65da41b9521a61f1c404dd58651f265a567 + audio_session: 088d2483ebd1dc43f51d253d4a1c517d9a2e7207 + CropViewController: 58fb440f30dac788b129d2a1f24cffdcb102669c + device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 + DKCamera: a902b66921fca14b7a75266feb8c7568aa7caa71 + DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac + DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 + file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + flutter_downloader: b7301ae057deadd4b1650dc7c05375f10ff12c39 + just_audio: baa7252489dbcf47a4c7cc9ca663e9661c99aafa + package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 + SDWebImage: e5cc87bf736e60f49592f307bdf9e157189298a3 + share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780 + url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe + +PODFILE CHECKSUM: 2129ec44a346995e229add1d94a10fae8b953738 + +COCOAPODS: 1.15.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..fbc394f --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,770 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 1A7A01C825617F84005D4731 /* libsqlite3.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A7A01C725617F74005D4731 /* libsqlite3.tbd */; }; + 1AB3434A28AEB3BA00B8C792 /* Info-Debug.plist in Resources */ = {isa = PBXBuildFile; fileRef = 1AB3434728AEB3BA00B8C792 /* Info-Debug.plist */; }; + 1AB3434B28AEB3BA00B8C792 /* Info-Profile.plist in Resources */ = {isa = PBXBuildFile; fileRef = 1AB3434828AEB3BA00B8C792 /* Info-Profile.plist */; }; + 1AB3434C28AEB3BA00B8C792 /* Info-Release.plist in Resources */ = {isa = PBXBuildFile; fileRef = 1AB3434928AEB3BA00B8C792 /* Info-Release.plist */; }; + 23119D278A5EBCAEDB53CE23 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FB7427A67C90F0206B85CCB6 /* Pods_Runner.framework */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 1A1D18DF2964B12600AFE66F /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Main.strings; sourceTree = ""; }; + 1A1D18E02964B12600AFE66F /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/LaunchScreen.strings; sourceTree = ""; }; + 1A1D18E12964B15800AFE66F /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Main.strings"; sourceTree = ""; }; + 1A1D18E22964B15800AFE66F /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/LaunchScreen.strings"; sourceTree = ""; }; + 1A1D18E32964B18F00AFE66F /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Main.strings; sourceTree = ""; }; + 1A1D18E42964B18F00AFE66F /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/LaunchScreen.strings; sourceTree = ""; }; + 1A3D4FB929E71AE500C17E1B /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Main.strings; sourceTree = ""; }; + 1A3D4FBA29E71AE500C17E1B /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/LaunchScreen.strings; sourceTree = ""; }; + 1A3D4FBB29E71B0500C17E1B /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Main.strings"; sourceTree = ""; }; + 1A3D4FBC29E71B0500C17E1B /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/LaunchScreen.strings"; sourceTree = ""; }; + 1A3D4FBD29E71B1200C17E1B /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Main.strings; sourceTree = ""; }; + 1A3D4FBE29E71B1200C17E1B /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/LaunchScreen.strings; sourceTree = ""; }; + 1A3D4FBF29E71B2C00C17E1B /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/Main.strings"; sourceTree = ""; }; + 1A3D4FC029E71B2C00C17E1B /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/LaunchScreen.strings"; sourceTree = ""; }; + 1A3D4FC129E71B4700C17E1B /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Main.strings; sourceTree = ""; }; + 1A3D4FC229E71B4700C17E1B /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/LaunchScreen.strings; sourceTree = ""; }; + 1A3D4FC329E71B4B00C17E1B /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Main.strings; sourceTree = ""; }; + 1A3D4FC429E71B4B00C17E1B /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/LaunchScreen.strings; sourceTree = ""; }; + 1A5F1B1929F734E500E6F504 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Main.strings; sourceTree = ""; }; + 1A5F1B1A29F734E500E6F504 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/LaunchScreen.strings; sourceTree = ""; }; + 1A7A01C725617F74005D4731 /* libsqlite3.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libsqlite3.tbd; path = usr/lib/libsqlite3.tbd; sourceTree = SDKROOT; }; + 1A9072D528AEB75800DB7A21 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Main.strings; sourceTree = ""; }; + 1A9072D628AEB75800DB7A21 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/LaunchScreen.strings; sourceTree = ""; }; + 1A9072D728AEB79200DB7A21 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Main.strings; sourceTree = ""; }; + 1A9072D828AEB79200DB7A21 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/LaunchScreen.strings; sourceTree = ""; }; + 1A9072D928AEB7A500DB7A21 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Main.strings; sourceTree = ""; }; + 1A9072DA28AEB7A500DB7A21 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/LaunchScreen.strings; sourceTree = ""; }; + 1A9072DB28AEB7DB00DB7A21 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Main.strings; sourceTree = ""; }; + 1A9072DC28AEB7DB00DB7A21 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/LaunchScreen.strings; sourceTree = ""; }; + 1A9072DD28AEB7E600DB7A21 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Main.strings; sourceTree = ""; }; + 1A9072DE28AEB7E600DB7A21 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/LaunchScreen.strings; sourceTree = ""; }; + 1AA9061928BB90D900DF9B20 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Main.strings; sourceTree = ""; }; + 1AA9061A28BB90D900DF9B20 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/LaunchScreen.strings; sourceTree = ""; }; + 1AA9061B28BB911A00DF9B20 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Main.strings; sourceTree = ""; }; + 1AA9061C28BB911A00DF9B20 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/LaunchScreen.strings; sourceTree = ""; }; + 1AA9061D28BB912A00DF9B20 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Main.strings; sourceTree = ""; }; + 1AA9061E28BB912A00DF9B20 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/LaunchScreen.strings; sourceTree = ""; }; + 1AB3434728AEB3BA00B8C792 /* Info-Debug.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Info-Debug.plist"; sourceTree = ""; }; + 1AB3434828AEB3BA00B8C792 /* Info-Profile.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Info-Profile.plist"; sourceTree = ""; }; + 1AB3434928AEB3BA00B8C792 /* Info-Release.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Info-Release.plist"; sourceTree = ""; }; + 1AC8B9DA2A73FD5E00200E3C /* zh-Hant-HK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant-HK"; path = "zh-Hant-HK.lproj/Main.strings"; sourceTree = ""; }; + 1AC8B9DB2A73FD5E00200E3C /* zh-Hant-HK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant-HK"; path = "zh-Hant-HK.lproj/LaunchScreen.strings"; sourceTree = ""; }; + 1AC8B9DC2A73FD7C00200E3C /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/Main.strings; sourceTree = ""; }; + 1AC8B9DD2A73FD7C00200E3C /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/LaunchScreen.strings; sourceTree = ""; }; + 1AC8B9DE2A73FD9000200E3C /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Main.strings; sourceTree = ""; }; + 1AC8B9DF2A73FD9000200E3C /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/LaunchScreen.strings; sourceTree = ""; }; + 1AC8B9E02A73FDB500200E3C /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Main.strings; sourceTree = ""; }; + 1AC8B9E12A73FDB500200E3C /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/LaunchScreen.strings; sourceTree = ""; }; + 1AC8B9E22A73FDC000200E3C /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Main.strings; sourceTree = ""; }; + 1AC8B9E32A73FDC000200E3C /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/LaunchScreen.strings; sourceTree = ""; }; + 1AD2DF3A2921A4A6006B24E3 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Main.strings; sourceTree = ""; }; + 1AD2DF3B2921A4A6006B24E3 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/LaunchScreen.strings; sourceTree = ""; }; + 1AD2DF3C2921A4AF006B24E3 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Main.strings; sourceTree = ""; }; + 1AD2DF3D2921A4AF006B24E3 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/LaunchScreen.strings; sourceTree = ""; }; + 1AD2DF3E2921A4C0006B24E3 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Main.strings; sourceTree = ""; }; + 1AD2DF3F2921A4C0006B24E3 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/LaunchScreen.strings; sourceTree = ""; }; + 1AD2DF402921A4D3006B24E3 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Main.strings"; sourceTree = ""; }; + 1AD2DF412921A4D3006B24E3 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/LaunchScreen.strings"; sourceTree = ""; }; + 1AD2DF422921A4DD006B24E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Main.strings; sourceTree = ""; }; + 1AD2DF432921A4DD006B24E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/LaunchScreen.strings; sourceTree = ""; }; + 1AD2DF442921A4FA006B24E3 /* szl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = szl; path = szl.lproj/Main.strings; sourceTree = ""; }; + 1AD2DF452921A4FA006B24E3 /* szl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = szl; path = szl.lproj/LaunchScreen.strings; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + CF84AA0DC653871661EC5D9F /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + D9AB2228B850BB2B1B359973 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + E0FC81896D87FAC6F1E1B2C8 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + FB7427A67C90F0206B85CCB6 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 1A7A01C825617F84005D4731 /* libsqlite3.tbd in Frameworks */, + 23119D278A5EBCAEDB53CE23 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 17DBD18F82197DAAAF6A81C9 /* Pods */ = { + isa = PBXGroup; + children = ( + E0FC81896D87FAC6F1E1B2C8 /* Pods-Runner.debug.xcconfig */, + CF84AA0DC653871661EC5D9F /* Pods-Runner.release.xcconfig */, + D9AB2228B850BB2B1B359973 /* Pods-Runner.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 4ACC266EDBF72BDB7F9715BE /* Frameworks */ = { + isa = PBXGroup; + children = ( + 1A7A01C725617F74005D4731 /* libsqlite3.tbd */, + FB7427A67C90F0206B85CCB6 /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 17DBD18F82197DAAAF6A81C9 /* Pods */, + 4ACC266EDBF72BDB7F9715BE /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 1AB3434728AEB3BA00B8C792 /* Info-Debug.plist */, + 1AB3434828AEB3BA00B8C792 /* Info-Profile.plist */, + 1AB3434928AEB3BA00B8C792 /* Info-Release.plist */, + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9C98541034167616AED82644 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + EA10DADE9639BB3D0A9BCBCE /* [CP] Embed Pods Frameworks */, + 2F42A0A37AC8A77603350B7A /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + de, + es, + nl, + pl, + sv, + ar, + fr, + it, + cs, + hu, + nb, + "pt-BR", + ru, + szl, + bg, + "zh-Hans", + et, + ca, + "zh-Hant", + da, + "pt-PT", + tr, + vi, + ja, + "zh-Hant-HK", + hr, + el, + th, + uk, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 1AB3434C28AEB3BA00B8C792 /* Info-Release.plist in Resources */, + 1AB3434A28AEB3BA00B8C792 /* Info-Debug.plist in Resources */, + 1AB3434B28AEB3BA00B8C792 /* Info-Profile.plist in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 2F42A0A37AC8A77603350B7A /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + 9C98541034167616AED82644 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + EA10DADE9639BB3D0A9BCBCE /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + 1A9072D528AEB75800DB7A21 /* de */, + 1A9072D728AEB79200DB7A21 /* es */, + 1A9072D928AEB7A500DB7A21 /* nl */, + 1A9072DB28AEB7DB00DB7A21 /* pl */, + 1A9072DD28AEB7E600DB7A21 /* sv */, + 1AA9061928BB90D900DF9B20 /* ar */, + 1AA9061B28BB911A00DF9B20 /* fr */, + 1AA9061D28BB912A00DF9B20 /* it */, + 1AD2DF3A2921A4A6006B24E3 /* cs */, + 1AD2DF3C2921A4AF006B24E3 /* hu */, + 1AD2DF3E2921A4C0006B24E3 /* nb */, + 1AD2DF402921A4D3006B24E3 /* pt-BR */, + 1AD2DF422921A4DD006B24E3 /* ru */, + 1AD2DF442921A4FA006B24E3 /* szl */, + 1A1D18DF2964B12600AFE66F /* bg */, + 1A1D18E12964B15800AFE66F /* zh-Hans */, + 1A1D18E32964B18F00AFE66F /* et */, + 1A3D4FB929E71AE500C17E1B /* ca */, + 1A3D4FBB29E71B0500C17E1B /* zh-Hant */, + 1A3D4FBD29E71B1200C17E1B /* da */, + 1A3D4FBF29E71B2C00C17E1B /* pt-PT */, + 1A3D4FC129E71B4700C17E1B /* tr */, + 1A3D4FC329E71B4B00C17E1B /* vi */, + 1A5F1B1929F734E500E6F504 /* ja */, + 1AC8B9DA2A73FD5E00200E3C /* zh-Hant-HK */, + 1AC8B9DC2A73FD7C00200E3C /* hr */, + 1AC8B9DE2A73FD9000200E3C /* el */, + 1AC8B9E02A73FDB500200E3C /* th */, + 1AC8B9E22A73FDC000200E3C /* uk */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + 1A9072D628AEB75800DB7A21 /* de */, + 1A9072D828AEB79200DB7A21 /* es */, + 1A9072DA28AEB7A500DB7A21 /* nl */, + 1A9072DC28AEB7DB00DB7A21 /* pl */, + 1A9072DE28AEB7E600DB7A21 /* sv */, + 1AA9061A28BB90D900DF9B20 /* ar */, + 1AA9061C28BB911A00DF9B20 /* fr */, + 1AA9061E28BB912A00DF9B20 /* it */, + 1AD2DF3B2921A4A6006B24E3 /* cs */, + 1AD2DF3D2921A4AF006B24E3 /* hu */, + 1AD2DF3F2921A4C0006B24E3 /* nb */, + 1AD2DF412921A4D3006B24E3 /* pt-BR */, + 1AD2DF432921A4DD006B24E3 /* ru */, + 1AD2DF452921A4FA006B24E3 /* szl */, + 1A1D18E02964B12600AFE66F /* bg */, + 1A1D18E22964B15800AFE66F /* zh-Hans */, + 1A1D18E42964B18F00AFE66F /* et */, + 1A3D4FBA29E71AE500C17E1B /* ca */, + 1A3D4FBC29E71B0500C17E1B /* zh-Hant */, + 1A3D4FBE29E71B1200C17E1B /* da */, + 1A3D4FC029E71B2C00C17E1B /* pt-PT */, + 1A3D4FC229E71B4700C17E1B /* tr */, + 1A3D4FC429E71B4B00C17E1B /* vi */, + 1A5F1B1A29F734E500E6F504 /* ja */, + 1AC8B9DB2A73FD5E00200E3C /* zh-Hant-HK */, + 1AC8B9DD2A73FD7C00200E3C /* hr */, + 1AC8B9DF2A73FD9000200E3C /* el */, + 1AC8B9E12A73FDB500200E3C /* th */, + 1AC8B9E32A73FDC000200E3C /* uk */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = PFNS8PTRM7; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist"; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.unicornsonlsd.finamp-ios"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = PFNS8PTRM7; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist"; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.unicornsonlsd.finamp-ios"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = PFNS8PTRM7; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = "Runner/Info-$(CONFIGURATION).plist"; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.unicornsonlsd.finamp-ios"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..5e31d3d --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..36ae744 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,33 @@ +import UIKit +import Flutter +import flutter_downloader + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + FlutterDownloaderPlugin.setPluginRegistrantCallback(registerPlugins) + + // Exclude the documents folder from iCloud backup since we keep songs there. + try! setExcludeFromiCloudBackup(isExcluded: true) + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} + +private func setExcludeFromiCloudBackup(isExcluded: Bool) throws { + var fileOrDirectoryURL = try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + var values = URLResourceValues() + values.isExcludedFromBackup = isExcluded + try fileOrDirectoryURL.setResourceValues(values) +} + + +private func registerPlugins(registry: FlutterPluginRegistry) { + if (!registry.hasPlugin("FlutterDownloaderPlugin")) { + FlutterDownloaderPlugin.register(with: registry.registrar(forPlugin: "FlutterDownloaderPlugin")!) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..0c2be8c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..5e5e710 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..196f948 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..a0b4f4a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..02340b1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..307d29e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..ccf9687 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..196f948 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..db7ceb6 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..8dbd52f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..8dbd52f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..2792911 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..3620b96 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..64cc42a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..dcb6b9b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info-Debug.plist b/ios/Runner/Info-Debug.plist new file mode 100644 index 0000000..c475a18 --- /dev/null +++ b/ios/Runner/Info-Debug.plist @@ -0,0 +1,79 @@ + + + + + NSPhotoLibraryUsageDescription + I don't know why this permission is being requested, you should probably deny it. If you can, create a GitHub issue with a screenshot of this message. + LSApplicationCategoryType + + NSAppleMusicUsageDescription + Finamp can use your media library to store downloaded songs. If you don't want this, Finamp can also download songs to the app's internal directory, which doesn't use any permissions. + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Finamp + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + finamp + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + FDMaximumConcurrentTasks + 4 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + UIBackgroundModes + + audio + fetch + remote-notification + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + NSBonjourServices + + _dartobservatory._tcp + + UIViewControllerBasedStatusBarAppearance + + NSLocalNetworkUsageDescription + Required for debugging + CADisableMinimumFrameDurationOnPhone + + + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + + + diff --git a/ios/Runner/Info-Profile.plist b/ios/Runner/Info-Profile.plist new file mode 100644 index 0000000..c475a18 --- /dev/null +++ b/ios/Runner/Info-Profile.plist @@ -0,0 +1,79 @@ + + + + + NSPhotoLibraryUsageDescription + I don't know why this permission is being requested, you should probably deny it. If you can, create a GitHub issue with a screenshot of this message. + LSApplicationCategoryType + + NSAppleMusicUsageDescription + Finamp can use your media library to store downloaded songs. If you don't want this, Finamp can also download songs to the app's internal directory, which doesn't use any permissions. + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Finamp + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + finamp + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + FDMaximumConcurrentTasks + 4 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + UIBackgroundModes + + audio + fetch + remote-notification + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + NSBonjourServices + + _dartobservatory._tcp + + UIViewControllerBasedStatusBarAppearance + + NSLocalNetworkUsageDescription + Required for debugging + CADisableMinimumFrameDurationOnPhone + + + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + + + diff --git a/ios/Runner/Info-Release.plist b/ios/Runner/Info-Release.plist new file mode 100644 index 0000000..e9b113d --- /dev/null +++ b/ios/Runner/Info-Release.plist @@ -0,0 +1,77 @@ + + + + + NSLocationWhenInUseUsageDescription + I don't know why this permission is being requested, you should probably deny it. If you can, create a GitHub issue with a screenshot of this message. + NSCameraUsageDescription + I don't know why this permission is being requested, you should probably deny it. If you can, create a GitHub issue with a screenshot of this message. + NSPhotoLibraryUsageDescription + I don't know why this permission is being requested, you should probably deny it. If you can, create a GitHub issue with a screenshot of this message. + LSApplicationCategoryType + + NSAppleMusicUsageDescription + Finamp can use your media library to store downloaded songs. If you don't want this, Finamp can also download songs to the app's internal directory, which doesn't use any permissions. + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Finamp + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + finamp + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + FDMaximumConcurrentTasks + 4 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + UIBackgroundModes + + audio + fetch + remote-notification + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIViewControllerBasedStatusBarAppearance + + + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/Runner/ar.lproj/LaunchScreen.strings b/ios/Runner/ar.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/ar.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/ar.lproj/Main.strings b/ios/Runner/ar.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/ar.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/bg.lproj/LaunchScreen.strings b/ios/Runner/bg.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/bg.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/bg.lproj/Main.strings b/ios/Runner/bg.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/bg.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/ca.lproj/LaunchScreen.strings b/ios/Runner/ca.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/ca.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/ca.lproj/Main.strings b/ios/Runner/ca.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/ca.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/cs.lproj/LaunchScreen.strings b/ios/Runner/cs.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/cs.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/cs.lproj/Main.strings b/ios/Runner/cs.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/cs.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/da.lproj/LaunchScreen.strings b/ios/Runner/da.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/da.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/da.lproj/Main.strings b/ios/Runner/da.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/da.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/de.lproj/LaunchScreen.strings b/ios/Runner/de.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/de.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/de.lproj/Main.strings b/ios/Runner/de.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/de.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/el.lproj/LaunchScreen.strings b/ios/Runner/el.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/el.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/el.lproj/Main.strings b/ios/Runner/el.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/el.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/es.lproj/LaunchScreen.strings b/ios/Runner/es.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/es.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/es.lproj/Main.strings b/ios/Runner/es.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/es.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/et.lproj/LaunchScreen.strings b/ios/Runner/et.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/et.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/et.lproj/Main.strings b/ios/Runner/et.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/et.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/fr.lproj/LaunchScreen.strings b/ios/Runner/fr.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/fr.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/fr.lproj/Main.strings b/ios/Runner/fr.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/fr.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/hr.lproj/LaunchScreen.strings b/ios/Runner/hr.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/hr.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/hr.lproj/Main.strings b/ios/Runner/hr.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/hr.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/hu.lproj/LaunchScreen.strings b/ios/Runner/hu.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/hu.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/hu.lproj/Main.strings b/ios/Runner/hu.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/hu.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/it.lproj/LaunchScreen.strings b/ios/Runner/it.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/it.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/it.lproj/Main.strings b/ios/Runner/it.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/it.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/ja.lproj/LaunchScreen.strings b/ios/Runner/ja.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/ja.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/ja.lproj/Main.strings b/ios/Runner/ja.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/ja.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/nb.lproj/LaunchScreen.strings b/ios/Runner/nb.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/nb.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/nb.lproj/Main.strings b/ios/Runner/nb.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/nb.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/nl.lproj/LaunchScreen.strings b/ios/Runner/nl.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/nl.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/nl.lproj/Main.strings b/ios/Runner/nl.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/nl.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/pl-PL.lproj/LaunchScreen.strings b/ios/Runner/pl-PL.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/pl-PL.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/pl-PL.lproj/Main.strings b/ios/Runner/pl-PL.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/pl-PL.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/pl.lproj/LaunchScreen.strings b/ios/Runner/pl.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/pl.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/pl.lproj/Main.strings b/ios/Runner/pl.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/pl.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/pt-BR.lproj/LaunchScreen.strings b/ios/Runner/pt-BR.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/pt-BR.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/pt-BR.lproj/Main.strings b/ios/Runner/pt-BR.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/pt-BR.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/pt-PT.lproj/LaunchScreen.strings b/ios/Runner/pt-PT.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/pt-PT.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/pt-PT.lproj/Main.strings b/ios/Runner/pt-PT.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/pt-PT.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/ru.lproj/LaunchScreen.strings b/ios/Runner/ru.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/ru.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/ru.lproj/Main.strings b/ios/Runner/ru.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/ru.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/sv.lproj/LaunchScreen.strings b/ios/Runner/sv.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/sv.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/sv.lproj/Main.strings b/ios/Runner/sv.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/sv.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/szl.lproj/LaunchScreen.strings b/ios/Runner/szl.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/szl.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/szl.lproj/Main.strings b/ios/Runner/szl.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/szl.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/th.lproj/LaunchScreen.strings b/ios/Runner/th.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/th.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/th.lproj/Main.strings b/ios/Runner/th.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/th.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/tr.lproj/LaunchScreen.strings b/ios/Runner/tr.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/tr.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/tr.lproj/Main.strings b/ios/Runner/tr.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/tr.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/uk.lproj/LaunchScreen.strings b/ios/Runner/uk.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/uk.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/uk.lproj/Main.strings b/ios/Runner/uk.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/uk.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/vi.lproj/LaunchScreen.strings b/ios/Runner/vi.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/vi.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/vi.lproj/Main.strings b/ios/Runner/vi.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/vi.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/zh-Hans.lproj/LaunchScreen.strings b/ios/Runner/zh-Hans.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/zh-Hans.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/zh-Hans.lproj/Main.strings b/ios/Runner/zh-Hans.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/zh-Hans.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/zh-Hant-HK.lproj/LaunchScreen.strings b/ios/Runner/zh-Hant-HK.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/zh-Hant-HK.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/zh-Hant-HK.lproj/Main.strings b/ios/Runner/zh-Hant-HK.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/zh-Hant-HK.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/zh-Hant.lproj/LaunchScreen.strings b/ios/Runner/zh-Hant.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/zh-Hant.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/zh-Hant.lproj/Main.strings b/ios/Runner/zh-Hant.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/zh-Hant.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/zh.lproj/LaunchScreen.strings b/ios/Runner/zh.lproj/LaunchScreen.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/zh.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/ios/Runner/zh.lproj/Main.strings b/ios/Runner/zh.lproj/Main.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ios/Runner/zh.lproj/Main.strings @@ -0,0 +1 @@ + diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..4e6692e --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,3 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart \ No newline at end of file diff --git a/lib/color_schemes.g.dart b/lib/color_schemes.g.dart new file mode 100644 index 0000000..d166415 --- /dev/null +++ b/lib/color_schemes.g.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; + +const jellyfinBlueColor = Color(0xFF00A4DC); +const jellyfinPurpleColor = Color(0xFFAA5CC3); + +const lightColorScheme = ColorScheme( + brightness: Brightness.light, + // Primary + primary: Color(0xFF00668A), + onPrimary: Color(0xFFFFFFFF), + primaryContainer: Color(0xFFC4E8FF), + onPrimaryContainer: Color(0xFF001E2C), + // Secondary + secondary: Color(0xFF406374), + onSecondary: Color(0xFFFFFFFF), + secondaryContainer: Color(0xFFCCE8F8), + onSecondaryContainer: Color(0xFF1B333F), + // Tertiary + tertiary: Color(0xFF893DA2), + onTertiary: Color(0xFFFFFFFF), + tertiaryContainer: Color(0xFFFAD7FF), + onTertiaryContainer: Color(0xFF330044), + // Error + error: Color(0xFFBA1A1A), + errorContainer: Color(0xFFFFDAD6), + onError: Color(0xFFFFFFFF), + onErrorContainer: Color(0xFF410002), + // Background & Surface + background: Color(0xFFFCFDFE), + onBackground: Color(0xFF191C1E), + surface: Color(0xFFFCFDFE), + onSurface: Color(0xFF191C1E), + surfaceVariant: Color(0xFFDDE4E8), + onSurfaceVariant: Color(0xFF41484D), + // Other colors + outline: Color(0xFF727A7F), + onInverseSurface: Color(0xFFF0F1F3), + inverseSurface: Color(0xFF2E3133), + inversePrimary: Color(0xFF7BD0FF), + shadow: Color(0xFF000000), + surfaceTint: Color(0xFF00668A), + outlineVariant: Color(0xFFC0C7CD), + scrim: Color(0xFF000000), +); + +const darkColorScheme = ColorScheme( + brightness: Brightness.dark, + // Primary + primary: jellyfinBlueColor, + onPrimary: Color(0xFF001E2C), + primaryContainer: Color(0xFF004C68), + onPrimaryContainer: Color(0xFFC3E7FF), + // Secondary + secondary: Color(0xFF60B4DD), + onSecondary: Color(0xFF112732), + secondaryContainer: Color(0xFF206B8C), + onSecondaryContainer: Color(0xFFCEEEFF), + // Tertiary + tertiary: Color(0xFFC979E2), + onTertiary: Color(0xFF3D0050), + tertiaryContainer: Color(0xFF762A90), + onTertiaryContainer: Color(0xFFFAD7FF), + // Error + error: Color(0xFFFFB4AB), + errorContainer: Color(0xFF93000A), + onError: Color(0xFF690005), + onErrorContainer: Color(0xFFFFDAD6), + // Background & Surface + background: Color(0xFF101315), + onBackground: Color(0xFFE1E2E5), + surface: Color(0xFF101315), + onSurface: Color(0xFFE1E2E5), + surfaceVariant: Color(0xFF333A3E), + onSurfaceVariant: Color(0xFFC0C7CD), + // Other colors + outline: Color(0xFF80878C), + onInverseSurface: Color(0xFF191C1E), + inverseSurface: Color(0xFFE1E2E5), + inversePrimary: Color(0xFF00668A), + shadow: Color(0xFF000000), + surfaceTint: Color(0xFF7BD0FF), + outlineVariant: Color(0xFF41484D), + scrim: Color(0xFF000000), +); \ No newline at end of file diff --git a/lib/components/AddDownloadLocationScreen/app_directory_location_form.dart b/lib/components/AddDownloadLocationScreen/app_directory_location_form.dart new file mode 100644 index 0000000..04db1c3 --- /dev/null +++ b/lib/components/AddDownloadLocationScreen/app_directory_location_form.dart @@ -0,0 +1,107 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../../models/finamp_models.dart'; + +class AppDirectoryLocationForm extends StatefulWidget { + const AppDirectoryLocationForm({Key? key, required this.formKey}) + : super(key: key); + + final Key formKey; + + @override + State createState() => + _AppDirectoryLocationFormState(); +} + +class _AppDirectoryLocationFormState extends State { + Directory? selectedDirectory; + late Future?> externalStorageListFuture; + + @override + void initState() { + super.initState(); + externalStorageListFuture = getExternalStorageDirectories(); + } + + @override + Widget build(BuildContext context) { + return Form( + key: widget.formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FutureBuilder?>( + future: externalStorageListFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + if (snapshot.data!.isEmpty) { + return const Text("No external directories."); + } + List> dropdownButtonItems = + snapshot.data! + .map((e) => DropdownMenuItem( + value: e, + child: Text( + e.path, + overflow: TextOverflow.ellipsis, + ), + )) + .toList(); + return DropdownButtonFormField( + items: dropdownButtonItems, + hint: const Text("Location"), + isExpanded: true, + value: selectedDirectory, + onChanged: (value) { + setState(() { + selectedDirectory = value; + }); + }, + validator: (value) { + if (value == null) { + return "Required"; + } + return null; + }, + onSaved: (newValue) { + if (newValue != null) { + context.read().path = newValue.path; + } + }, + ); + } else if (snapshot.hasError) { + return Text(snapshot.error.toString()); + } else { + return const CircularProgressIndicator.adaptive(); + } + }, + ), + TextFormField( + decoration: const InputDecoration(labelText: "Name (required)"), + validator: (value) { + if (value == null || value.isEmpty) { + return "Required"; + } + return null; + }, + onSaved: (newValue) { + if (newValue != null) { + context.read().name = newValue; + } + }, + ), + const Padding(padding: EdgeInsets.all(8.0)), + Text( + "If the path doesn't contain \"emulated\", it is proably external storage.", + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } +} diff --git a/lib/components/AddDownloadLocationScreen/custom_download_location_form.dart b/lib/components/AddDownloadLocationScreen/custom_download_location_form.dart new file mode 100644 index 0000000..e24ea00 --- /dev/null +++ b/lib/components/AddDownloadLocationScreen/custom_download_location_form.dart @@ -0,0 +1,152 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:provider/provider.dart'; + +import '../../models/finamp_models.dart'; + +class CustomDownloadLocationForm extends StatefulWidget { + const CustomDownloadLocationForm({Key? key, required this.formKey}) + : super(key: key); + + final Key formKey; + + @override + State createState() => + _CustomDownloadLocationFormState(); +} + +class _CustomDownloadLocationFormState + extends State { + Directory? selectedDirectory; + + @override + Widget build(BuildContext context) { + return Form( + key: widget.formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FormField( + builder: (field) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(4)), + child: Material( + color: Theme.of(context).colorScheme.secondaryContainer, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + selectedDirectory == null + ? AppLocalizations.of(context)! + .selectDirectory + : selectedDirectory!.path.replaceFirst( + "${selectedDirectory!.parent.path}/", + ""), + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: selectedDirectory == null + ? Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + color: Theme.of(context).hintColor, + ) + : Theme.of(context).textTheme.titleMedium, + ), + ), + IconButton( + icon: const Icon(Icons.folder), + onPressed: () async { + String? newPath = await FilePicker.platform + .getDirectoryPath(); + + if (newPath != null) { + setState(() { + selectedDirectory = Directory(newPath); + }); + } + }), + ], + ), + ), + ), + ), + if (field.hasError) + Padding( + padding: const EdgeInsets.fromLTRB(0, 4, 0, 0), + child: Text( + field.errorText ?? + AppLocalizations.of(context)!.unknownError, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ); + }, + validator: (_) { + if (selectedDirectory == null) { + return AppLocalizations.of(context)!.required; + } + + // There are a load of null checks here, but since we've already + // checked if selectedDirectory is null we should be fine. + + if (selectedDirectory!.path == "/") { + return AppLocalizations.of(context)! + .pathReturnSlashErrorMessage; + } + + // This checks if the chosen directory is empty + if (selectedDirectory! + .listSync() + .where((event) => !event.path + .replaceFirst(selectedDirectory!.path, "") + .contains(".")) + .isNotEmpty) { + return AppLocalizations.of(context)!.directoryMustBeEmpty; + } + return null; + }, + onSaved: (_) { + if (selectedDirectory != null) { + context.read().path = + selectedDirectory!.path; + } + }, + ), + TextFormField( + decoration: + InputDecoration(labelText: AppLocalizations.of(context)!.name), + validator: (value) { + if (value == null || value.isEmpty) { + return AppLocalizations.of(context)!.required; + } + return null; + }, + onSaved: (newValue) { + if (newValue != null) { + context.read().name = newValue; + } + }, + ), + const Padding(padding: EdgeInsets.all(8.0)), + Text(AppLocalizations.of(context)!.customLocationsBuggy, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red)), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/components/AddToPlaylistScreen/add_to_playlist_list.dart b/lib/components/AddToPlaylistScreen/add_to_playlist_list.dart new file mode 100644 index 0000000..2426694 --- /dev/null +++ b/lib/components/AddToPlaylistScreen/add_to_playlist_list.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; + +import '../../models/jellyfin_models.dart'; +import '../../services/jellyfin_api_helper.dart'; +import '../MusicScreen/album_item.dart'; +import '../error_snackbar.dart'; + +class AddToPlaylistList extends StatefulWidget { + const AddToPlaylistList({ + Key? key, + required this.itemToAddId, + }) : super(key: key); + + final String itemToAddId; + + @override + State createState() => _AddToPlaylistListState(); +} + +class _AddToPlaylistListState extends State { + final jellyfinApiHelper = GetIt.instance(); + late Future?> addToPlaylistListFuture; + + @override + void initState() { + super.initState(); + addToPlaylistListFuture = jellyfinApiHelper.getItems( + includeItemTypes: "Playlist", + sortBy: "SortName", + isGenres: false, + ); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder?>( + future: addToPlaylistListFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + return Scrollbar( + child: ListView.builder( + itemCount: snapshot.data!.length, + itemBuilder: (context, index) { + return AlbumItem( + album: snapshot.data![index], + parentType: snapshot.data![index].type, + onTap: () async { + try { + await jellyfinApiHelper.addItemstoPlaylist( + playlistId: snapshot.data![index].id, + ids: [widget.itemToAddId], + ); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Added to playlist."), + // action: SnackBarAction( + // label: "OPEN", + // onPressed: () { + // Navigator.of(context).pushNamed( + // "/music/albumscreen", + // arguments: snapshot.data![index]); + // }, + // ), + ), + ); + Navigator.pop(context); + } catch (e) { + errorSnackbar(e, context); + return; + } + }, + ); + }, + ), + ); + } else if (snapshot.hasError) { + errorSnackbar(snapshot.error, context); + return const Center( + child: Icon(Icons.error, size: 64), + ); + } else { + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + } + }, + ); + } +} diff --git a/lib/components/AddToPlaylistScreen/new_playlist_dialog.dart b/lib/components/AddToPlaylistScreen/new_playlist_dialog.dart new file mode 100644 index 0000000..9b8c2e3 --- /dev/null +++ b/lib/components/AddToPlaylistScreen/new_playlist_dialog.dart @@ -0,0 +1,93 @@ +import 'package:finamp/models/jellyfin_models.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; + +import '../../services/finamp_user_helper.dart'; +import '../../services/jellyfin_api_helper.dart'; +import '../error_snackbar.dart'; + +class NewPlaylistDialog extends StatefulWidget { + const NewPlaylistDialog({ + Key? key, + required this.itemToAdd, + }) : super(key: key); + + final String itemToAdd; + + @override + State createState() => _NewPlaylistDialogState(); +} + +class _NewPlaylistDialogState extends State { + final _formKey = GlobalKey(); + final _jellyfinApiHelper = GetIt.instance(); + final _finampUserHelper = GetIt.instance(); + + bool _isSubmitting = false; + + String? _name; + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(AppLocalizations.of(context)!.newPlaylist), + content: Form( + key: _formKey, + child: TextFormField( + decoration: + InputDecoration(labelText: AppLocalizations.of(context)!.name), + textInputAction: TextInputAction.done, + validator: (value) { + if (value == null || value.isEmpty) { + return AppLocalizations.of(context)!.required; + } + return null; + }, + onFieldSubmitted: (_) async => await _submit(), + onSaved: (newValue) => _name = newValue, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: _isSubmitting ? null : () async => await _submit(), + child: Text(AppLocalizations.of(context)!.createButtonLabel), + ), + ], + ); + } + + Future _submit() async { + if (_formKey.currentState != null && _formKey.currentState!.validate()) { + setState(() { + _isSubmitting = true; + }); + + _formKey.currentState!.save(); + + try { + await _jellyfinApiHelper.createNewPlaylist(NewPlaylist( + name: _name, + ids: [widget.itemToAdd], + userId: _finampUserHelper.currentUser!.id, + )); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.playlistCreated), + )); + Navigator.of(context).pop(true); + } catch (e) { + errorSnackbar(e, context); + setState(() { + _isSubmitting = false; + }); + return; + } + } + } +} diff --git a/lib/components/AlbumScreen/album_screen_content.dart b/lib/components/AlbumScreen/album_screen_content.dart new file mode 100644 index 0000000..526b521 --- /dev/null +++ b/lib/components/AlbumScreen/album_screen_content.dart @@ -0,0 +1,207 @@ +import 'package:finamp/components/AlbumScreen/sync_album_or_playlist_button.dart'; +import 'package:finamp/services/downloads_helper.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_sticky_header/flutter_sticky_header.dart'; +import 'package:get_it/get_it.dart'; + +import '../../models/jellyfin_models.dart'; +import '../../services/finamp_settings_helper.dart'; +import '../../components/favourite_button.dart'; +import 'album_screen_content_flexible_space_bar.dart'; +import 'delete_button.dart'; +import 'song_list_tile.dart'; +import 'playlist_name_edit_button.dart'; + +typedef BaseItemDtoCallback = void Function(BaseItemDto item); + +class AlbumScreenContent extends StatefulWidget { + const AlbumScreenContent({ + Key? key, + required this.parent, + required this.children, + }) : super(key: key); + + final BaseItemDto parent; + final List children; + + @override + State createState() => _AlbumScreenContentState(); +} + +class _AlbumScreenContentState extends State { + @override + Widget build(BuildContext context) { + void onDelete(BaseItemDto item) { + // This is pretty inefficient (has to search through whole list) but + // SongsSliverList gets passed some weird split version of children to + // handle multi-disc albums and it's 00:35 so I can't be bothered to get + // it to return an index + setState(() { + widget.children.remove(item); + }); + } + + List> childrenPerDisc = []; + // if not in playlist, try splitting up tracks by disc numbers + // if first track has a disc number, let's assume the rest has it too + if (widget.parent.type != "Playlist" && + widget.children[0].parentIndexNumber != null) { + int? lastDiscNumber; + for (var child in widget.children) { + if (child.parentIndexNumber != null && + child.parentIndexNumber != lastDiscNumber) { + lastDiscNumber = child.parentIndexNumber; + childrenPerDisc.add([]); + } + childrenPerDisc.last.add(child); + } + } + + return Scrollbar( + child: CustomScrollView( + slivers: [ + SliverAppBar( + title: Text(widget.parent.name ?? + AppLocalizations.of(context)!.unknownName), + // 125 + 64 is the total height of the widget we use as a + // FlexibleSpaceBar. We add the toolbar height since the widget + // should appear below the appbar. + // TODO: This height is affected by platform density. + expandedHeight: kToolbarHeight + 125 + 64, + pinned: true, + flexibleSpace: AlbumScreenContentFlexibleSpaceBar( + album: widget.parent, + items: widget.children, + ), + actions: [ + if (widget.parent.type == "Playlist" && + !FinampSettingsHelper.finampSettings.isOffline) + PlaylistNameEditButton(playlist: widget.parent), + FavoriteButton(item: widget.parent), + if (GetIt.instance().isAlbumDownloaded(widget.parent.id)) + DeleteButton(parent: widget.parent, items: widget.children), + if (!FinampSettingsHelper.finampSettings.isOffline) + SyncAlbumOrPlaylistButton(parent: widget.parent, items: widget.children) + ], + ), + if (widget.children.length > 1 && + childrenPerDisc.length > + 1) // show headers only for multi disc albums + for (var childrenOfThisDisc in childrenPerDisc) + SliverStickyHeader( + header: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 16.0, + ), + color: Theme.of(context).colorScheme.surfaceVariant, + child: Text( + AppLocalizations.of(context)! + .discNumber(childrenOfThisDisc[0].parentIndexNumber!), + style: const TextStyle(fontSize: 20.0), + ), + ), + sliver: SongsSliverList( + childrenForList: childrenOfThisDisc, + childrenForQueue: widget.children, + parent: widget.parent, + onDelete: onDelete, + ), + ) + else if (widget.children.isNotEmpty) + SongsSliverList( + childrenForList: widget.children, + childrenForQueue: widget.children, + parent: widget.parent, + onDelete: onDelete, + ), + ], + ), + ); + } +} + +class SongsSliverList extends StatefulWidget { + const SongsSliverList({ + Key? key, + required this.childrenForList, + required this.childrenForQueue, + required this.parent, + this.onDelete, + }) : super(key: key); + + final List childrenForList; + final List childrenForQueue; + final BaseItemDto parent; + final BaseItemDtoCallback? onDelete; + + @override + State createState() => _SongsSliverListState(); +} + +class _SongsSliverListState extends State { + final GlobalKey sliverListKey = + GlobalKey(); + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + // When user selects song from disc other than first, index number is + // incorrect and song with the same index on first disc is played instead. + // Adding this offset ensures playback starts for nth song on correct disc. + final int indexOffset = + widget.childrenForQueue.indexOf(widget.childrenForList[0]); + + return SliverList( + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + final BaseItemDto item = widget.childrenForList[index]; + + BaseItemDto removeItem() { + late BaseItemDto item; + + setState(() { + item = widget.childrenForList.removeAt(index); + }); + + return item; + } + + return SongListTile( + item: item, + children: widget.childrenForQueue, + index: index + indexOffset, + parentId: widget.parent.id, + onDelete: () { + final item = removeItem(); + if (widget.onDelete != null) { + widget.onDelete!(item); + } + }, + isInPlaylist: widget.parent.type == "Playlist", + // show artists except for this one scenario + showArtists: !( + // we're on album screen + widget.parent.type == "MusicAlbum" + // "hide song artists if they're the same as album artists" == true + && + FinampSettingsHelper + .finampSettings.hideSongArtistsIfSameAsAlbumArtists + // song artists == album artists + && + setEquals( + widget.parent.albumArtists?.map((e) => e.name).toSet(), + item.artists?.toSet())), + ); + }, + childCount: widget.childrenForList.length, + ), + ); + } +} \ No newline at end of file diff --git a/lib/components/AlbumScreen/album_screen_content_flexible_space_bar.dart b/lib/components/AlbumScreen/album_screen_content_flexible_space_bar.dart new file mode 100644 index 0000000..4cf767b --- /dev/null +++ b/lib/components/AlbumScreen/album_screen_content_flexible_space_bar.dart @@ -0,0 +1,92 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; + +import '../../models/jellyfin_models.dart'; +import '../../services/audio_service_helper.dart'; +import '../album_image.dart'; +import 'item_info.dart'; + +class AlbumScreenContentFlexibleSpaceBar extends StatelessWidget { + const AlbumScreenContentFlexibleSpaceBar({ + Key? key, + required this.album, + required this.items, + }) : super(key: key); + + final BaseItemDto album; + final List items; + + @override + Widget build(BuildContext context) { + AudioServiceHelper audioServiceHelper = + GetIt.instance(); + + return FlexibleSpaceBar( + background: SafeArea( + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + SizedBox( + height: 125, + child: AlbumImage(item: album), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 4), + ), + Expanded( + flex: 2, + child: ItemInfo( + item: album, + itemSongs: items.length, + ), + ) + ], + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row(children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () => + audioServiceHelper.replaceQueueWithItem( + itemList: items, + ), + icon: const Icon(Icons.play_arrow), + label: + Text(AppLocalizations.of(context)!.playButtonLabel), + ), + ), + const Padding(padding: EdgeInsets.symmetric(horizontal: 8)), + Expanded( + child: ElevatedButton.icon( + onPressed: () => + audioServiceHelper.replaceQueueWithItem( + itemList: items, + shuffle: true, + initialIndex: Random().nextInt(items.length), + ), + icon: const Icon(Icons.shuffle), + label: Text( + AppLocalizations.of(context)!.shuffleButtonLabel), + ), + ), + ]), + ) + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/components/AlbumScreen/delete_button.dart b/lib/components/AlbumScreen/delete_button.dart new file mode 100644 index 0000000..5619ca3 --- /dev/null +++ b/lib/components/AlbumScreen/delete_button.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; +import 'package:hive/hive.dart'; + +import '../../services/downloads_helper.dart'; +import '../../services/finamp_settings_helper.dart'; +import '../../models/jellyfin_models.dart'; +import '../../models/finamp_models.dart'; +import '../confirmation_prompt_dialog.dart'; +import '../error_snackbar.dart'; + +class DeleteButton extends StatefulWidget { + const DeleteButton({ + Key? key, + required this.parent, + required this.items, + }) : super(key: key); + + final BaseItemDto parent; + final List items; + + @override + State createState() => _DeleteButtonState(); +} + +class _DeleteButtonState extends State { + final _downloadsHelper = GetIt.instance(); + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + void checkIfDownloaded() { + if (!mounted) return; + } + + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (context, box, child) { + bool? isOffline = box.get("FinampSettings")?.isOffline; + + return IconButton( + tooltip: AppLocalizations.of(context)!.deleteFromDevice, + icon: const Icon(Icons.delete), + // If offline, we don't allow the user to delete items. + // If we did, we'd have to implement listeners for MusicScreenTabView so that the user can't delete a parent, go back, and select the same parent. + // If they did, AlbumScreen would show an error since the item no longer exists. + // Also, the user could delete the parent and immediately re-download it, which will either cause unwanted network usage or cause more errors because the user is offline. + onPressed: isOffline ?? false + ? null + : () { + showDialog( + context: context, + builder: (context) => ConfirmationPromptDialog( + promptText: AppLocalizations.of(context)! + .deleteDownloadsPrompt( + widget.parent.name ?? "", + widget.parent.type == "Playlist" + ? "playlist" + : "album"), + confirmButtonText: AppLocalizations.of(context)! + .deleteDownloadsConfirmButtonText, + abortButtonText: AppLocalizations.of(context)! + .deleteDownloadsAbortButtonText, + onConfirmed: () async { + final messenger = ScaffoldMessenger.of(context); + try { + await _downloadsHelper + .deleteParentAndChildDownloads( + jellyfinItemIds: + widget.items.map((e) => e.id).toList(), + deletedFor: widget.parent.id, + ); + checkIfDownloaded(); + messenger.showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)! + .downloadsDeleted))); + } catch (error) { + errorSnackbar(error, context); + } + }, + onAborted: () {}, + ), + ); + }); + }, + ); + } +} diff --git a/lib/components/AlbumScreen/download_dialog.dart b/lib/components/AlbumScreen/download_dialog.dart new file mode 100644 index 0000000..efd5df0 --- /dev/null +++ b/lib/components/AlbumScreen/download_dialog.dart @@ -0,0 +1,101 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logging/logging.dart'; + +import '../../services/finamp_settings_helper.dart'; +import '../../services/downloads_helper.dart'; +import '../../models/finamp_models.dart'; +import '../../models/jellyfin_models.dart'; +import '../error_snackbar.dart'; + +class DownloadDialog extends StatefulWidget { + const DownloadDialog({ + Key? key, + required this.parents, + required this.items, + required this.viewId, + }) : assert(parents.length == items.length), + super(key: key); + + final List parents; + final List> items; + final String viewId; + + @override + State createState() => _DownloadDialogState(); +} + +class _DownloadDialogState extends State { + DownloadsHelper downloadsHelper = GetIt.instance(); + DownloadLocation? selectedDownloadLocation; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(AppLocalizations.of(context)!.addDownloads), + content: DropdownButton( + hint: Text(AppLocalizations.of(context)!.location), + isExpanded: true, + onChanged: (value) => setState(() { + selectedDownloadLocation = value; + }), + value: selectedDownloadLocation, + items: FinampSettingsHelper.finampSettings.downloadLocationsMap.values + .map((e) => DropdownMenuItem( + value: e, + child: Text(e.name), + )) + .toList()), + actions: [ + TextButton( + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + onPressed: () => Navigator.of(context).pop(), + ), + TextButton( + onPressed: selectedDownloadLocation == null + ? null + : () async { + Navigator.of(context).pop(selectedDownloadLocation); + }, + child: Text(AppLocalizations.of(context)!.addButtonLabel), + ) + ], + ); + } +} + +/// This function is used by DownloadDialog to check/add downloads. +Future checkedAddDownloads( + BuildContext context, { + required DownloadLocation downloadLocation, + required List parents, + required List> items, + required String viewId, +}) async { + final downloadsHelper = GetIt.instance(); + final checkedAddDownloadsLogger = Logger("CheckedAddDownloads"); + + // If the default "internal storage" path is set and doesn't + // exist, it may have been moved by an iOS update. + if (downloadLocation.useHumanReadableNames == false && + !await Directory(downloadLocation.path).exists()) { + checkedAddDownloadsLogger + .warning("Internal storage path doesn't exist! Resetting."); + await FinampSettingsHelper.resetDefaultDownloadLocation(); + } + + for (int i = 0; i < parents.length; i++) { + downloadsHelper + .addDownloads( + parent: parents[i], + items: items[i], + useHumanReadableNames: downloadLocation.useHumanReadableNames, + viewId: viewId, + downloadLocation: downloadLocation, + ) + .onError((error, stackTrace) => errorSnackbar(error, context)); + } +} diff --git a/lib/components/AlbumScreen/downloaded_indicator.dart b/lib/components/AlbumScreen/downloaded_indicator.dart new file mode 100644 index 0000000..ea3205d --- /dev/null +++ b/lib/components/AlbumScreen/downloaded_indicator.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_downloader/flutter_downloader.dart'; +import 'package:get_it/get_it.dart'; +import 'package:hive/hive.dart'; + +import '../../models/finamp_models.dart'; +import '../../services/downloads_helper.dart'; +import '../../services/download_update_stream.dart'; +import '../../models/jellyfin_models.dart'; +import '../error_snackbar.dart'; + +class DownloadedIndicator extends StatefulWidget { + const DownloadedIndicator({ + Key? key, + required this.item, + this.size, + }) : super(key: key); + + final BaseItemDto item; + final double? size; + + @override + State createState() => _DownloadedIndicatorState(); +} + +class _DownloadedIndicatorState extends State { + final _downloadsHelper = GetIt.instance(); + final _downloadUpdateStream = GetIt.instance(); + + late Future?> _downloadedIndicatorFuture; + String? _downloadTaskId; + + DownloadTaskStatus? _currentStatus; + + @override + void initState() { + super.initState(); + _downloadedIndicatorFuture = + _downloadsHelper.getDownloadStatus([widget.item.id]); + + // We do this instead of using a StreamBuilder because the StreamBuilder + // kept dropping events. With this, we can also make it so that the widget + // only rebuilds when it actually has to. + _downloadUpdateStream.stream.listen((event) { + if (event.id == _downloadTaskId && event.status != _currentStatus) { + setState(() { + _currentStatus = event.status; + }); + } + }); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder?>( + future: _downloadedIndicatorFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + if (snapshot.data != null && snapshot.data!.isNotEmpty) { + _downloadTaskId = snapshot.data?[0].taskId; + _currentStatus = snapshot.data?[0].status; + } + // This ValueListenable is used to get the download task ID of new + // downloads. It also clears the task ID and status when the download + // is deleted. This only rebuilds when the item with key + // widget.item.id is changed, so this should only rebuild when the + // item is first downloaded and when it is deleted. + return ValueListenableBuilder>( + valueListenable: _downloadsHelper + .getDownloadedItemsListenable(keys: [widget.item.id]), + builder: (context, box, _) { + if (_downloadTaskId == null && box.get(widget.item.id) != null) { + _downloadTaskId = box.get(widget.item.id)!.downloadId; + } else if (box.get(widget.item.id) == null) { + _downloadTaskId = null; + _currentStatus = null; + } + // return StreamBuilder( + // stream: _downloadUpdateStream.stream, + // builder: (context, snapshot) { + // print("Streambuilder rebuild ${snapshot.data}"); + // if (snapshot.hasData && + // snapshot.data!.id == _downloadTask?.taskId) { + // print("Change $_currentStatus to ${snapshot.data?.status}"); + // if (snapshot.data?.status == DownloadTaskStatus.complete) { + // print( + // "COMPLETE IN STREAMBUILDER ${widget.item.name}!!!!"); + // } + // _currentStatus = snapshot.data?.status; + // } + if (_currentStatus == null) { + return const SizedBox.shrink(); + } else if (_currentStatus == DownloadTaskStatus.complete) { + return Icon( + Icons.download, + color: Theme.of(context).colorScheme.secondary, + size: widget.size, + ); + } else if (_currentStatus == DownloadTaskStatus.failed || + _currentStatus == DownloadTaskStatus.undefined) { + return Icon( + Icons.error, + color: Colors.red, + size: widget.size, + ); + } else if (_currentStatus == DownloadTaskStatus.paused) { + return Icon( + Icons.pause, + color: Colors.yellow, + size: widget.size, + ); + } else if (_currentStatus == DownloadTaskStatus.enqueued || + _currentStatus == DownloadTaskStatus.running) { + return Icon( + Icons.download_outlined, + color: Colors.white.withOpacity(0.5), + size: widget.size, + ); + } else { + return const SizedBox.shrink(); + } + // }, + // ); + }, + ); + } else if (snapshot.hasError) { + errorSnackbar(snapshot.error, context); + return const SizedBox.shrink(); + } else { + return const SizedBox.shrink(); + } + }, + ); + } +} \ No newline at end of file diff --git a/lib/components/AlbumScreen/item_info.dart b/lib/components/AlbumScreen/item_info.dart new file mode 100644 index 0000000..a229270 --- /dev/null +++ b/lib/components/AlbumScreen/item_info.dart @@ -0,0 +1,109 @@ +import 'package:finamp/components/artists_text_spans.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../../models/jellyfin_models.dart'; +import '../print_duration.dart'; + +class ItemInfo extends StatelessWidget { + const ItemInfo({ + Key? key, + required this.item, + required this.itemSongs, + }) : super(key: key); + + final BaseItemDto item; + final int itemSongs; + +// TODO: see if there's a way to expand this column to the row that it's in + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (item.type != "Playlist") + _IconAndText( + iconData: Icons.person, + textSpan: TextSpan( + children: ArtistsTextSpans( + item, + Theme.of(context).colorScheme.onSurface, + context, + false, + ), + )), + _IconAndText( + iconData: Icons.music_note, + textSpan: _buildStyledText( + context: context, + text: AppLocalizations.of(context)!.songCount(itemSongs), + ), + ), + _IconAndText( + iconData: Icons.timer, + textSpan: _buildStyledText( + context: context, + text: printDuration(Duration( + microseconds: + item.runTimeTicks == null ? 0 : item.runTimeTicks! ~/ 10, + )), + ), + ), + if (item.type != "Playlist") + _IconAndText( + iconData: Icons.event, + textSpan: _buildStyledText( + context: context, + text: item.productionYearString, + ), + ) + ], + ); + } + + TextSpan _buildStyledText({required BuildContext context, String? text}) { + return TextSpan( + text: text, + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), + ); + } +} + +class _IconAndText extends StatelessWidget { + const _IconAndText({ + Key? key, + required this.iconData, + required this.textSpan, + }) : super(key: key); + + final IconData iconData; + final TextSpan textSpan; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + iconData, + // Inactive icons have an opacity of 50% with dark theme and 38% + // with bright theme + // https://material.io/design/iconography/system-icons.html#color + color: Theme.of(context).disabledColor, + ), + const Padding(padding: EdgeInsets.symmetric(horizontal: 2)), + Expanded( + child: RichText( + text: textSpan, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ) + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/components/AlbumScreen/playlist_name_edit_button.dart b/lib/components/AlbumScreen/playlist_name_edit_button.dart new file mode 100644 index 0000000..de161a5 --- /dev/null +++ b/lib/components/AlbumScreen/playlist_name_edit_button.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../../models/jellyfin_models.dart'; +import 'playlist_name_edit_dialog.dart'; + +class PlaylistNameEditButton extends StatelessWidget { + const PlaylistNameEditButton({ + Key? key, + required this.playlist, + }) : super(key: key); + + final BaseItemDto playlist; + + @override + Widget build(BuildContext context) { + return IconButton( + icon: const Icon(Icons.edit), + tooltip: AppLocalizations.of(context)!.editPlaylistNameTooltip, + onPressed: () => showDialog( + context: context, + builder: (context) => PlaylistNameEditDialog(playlist: playlist), + ), + ); + } +} diff --git a/lib/components/AlbumScreen/playlist_name_edit_dialog.dart b/lib/components/AlbumScreen/playlist_name_edit_dialog.dart new file mode 100644 index 0000000..640311f --- /dev/null +++ b/lib/components/AlbumScreen/playlist_name_edit_dialog.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; + +import '../../models/jellyfin_models.dart'; +import '../../services/jellyfin_api_helper.dart'; +import '../error_snackbar.dart'; + +class PlaylistNameEditDialog extends StatefulWidget { + const PlaylistNameEditDialog({ + Key? key, + required this.playlist, + }) : super(key: key); + + final BaseItemDto playlist; + + @override + State createState() => _PlaylistNameEditDialogState(); +} + +class _PlaylistNameEditDialogState extends State { + String? _name; + bool _isUpdating = false; + + final _jellyfinApiHelper = GetIt.instance(); + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _name = widget.playlist.name; + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(AppLocalizations.of(context)!.editPlaylistNameTitle), + content: Form( + key: _formKey, + child: TextFormField( + initialValue: _name, + decoration: + InputDecoration(labelText: AppLocalizations.of(context)!.name), + textInputAction: TextInputAction.done, + validator: (value) { + if (value == null || value.isEmpty) { + return AppLocalizations.of(context)!.required; + } + return null; + }, + onFieldSubmitted: (_) async => await _submit(), + onSaved: (newValue) => _name = newValue, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: _isUpdating ? null : () async => await _submit(), + child: Text(AppLocalizations.of(context)!.updateButtonLabel), + ), + ], + ); + } + + Future _submit() async { + if (_formKey.currentState != null && _formKey.currentState!.validate()) { + setState(() { + _isUpdating = true; + }); + + _formKey.currentState!.save(); + + try { + BaseItemDto playlistTemp = widget.playlist; + playlistTemp.name = _name; + await _jellyfinApiHelper.updateItem( + itemId: widget.playlist.id, + newItem: playlistTemp, + ); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.playlistNameUpdated), + )); + Navigator.of(context).pop(); + } catch (e) { + errorSnackbar(e, context); + setState(() { + _isUpdating = false; + }); + return; + } + } + } +} diff --git a/lib/components/AlbumScreen/song_list_tile.dart b/lib/components/AlbumScreen/song_list_tile.dart new file mode 100644 index 0000000..974fbe5 --- /dev/null +++ b/lib/components/AlbumScreen/song_list_tile.dart @@ -0,0 +1,512 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mini_music_visualizer/mini_music_visualizer.dart'; + +import '../../models/jellyfin_models.dart'; +import '../../screens/add_to_playlist_screen.dart'; +import '../../screens/album_screen.dart'; +import '../../services/audio_service_helper.dart'; +import '../../services/downloads_helper.dart'; +import '../../services/finamp_settings_helper.dart'; +import '../../services/jellyfin_api_helper.dart'; +import '../../services/media_state_stream.dart'; +import '../../services/process_artist.dart'; +import '../album_image.dart'; +import '../error_snackbar.dart'; +import '../favourite_button.dart'; +import '../print_duration.dart'; +import 'downloaded_indicator.dart'; + +enum SongListTileMenuItems { + addToQueue, + playNext, + replaceQueueWithItem, + addToPlaylist, + removeFromPlaylist, + instantMix, + goToAlbum, + addFavourite, + removeFavourite, +} + +class SongListTile extends StatefulWidget { + const SongListTile({ + Key? key, + required this.item, + + /// Children that are related to this list tile, such as the other songs in + /// the album. This is used to give the audio service all the songs for the + /// item. If null, only this song will be given to the audio service. + this.children, + + /// Index of the song in whatever parent this widget is in. Used to start + /// the audio service at a certain index, such as when selecting the middle + /// song in an album. + this.index, + this.parentId, + this.isSong = false, + this.showArtists = true, + this.onDelete, + + /// Whether this widget is being displayed in a playlist. If true, will show + /// the remove from playlist button. + this.isInPlaylist = false, + }) : super(key: key); + + final BaseItemDto item; + final List? children; + final int? index; + final bool isSong; + final String? parentId; + final bool showArtists; + final VoidCallback? onDelete; + final bool isInPlaylist; + + @override + State createState() => _SongListTileState(); +} + +class _SongListTileState extends State { + final _audioServiceHelper = GetIt.instance(); + final _jellyfinApiHelper = GetIt.instance(); + + @override + Widget build(BuildContext context) { + final screenSize = MediaQuery.of(context).size; + + /// Sets the item's favourite on the Jellyfin server. + Future setFavourite() async { + try { + // We switch the widget state before actually doing the request to + // make the app feel faster (without, there is a delay from the + // user adding the favourite and the icon showing) + setState(() { + widget.item.userData!.isFavorite = !widget.item.userData!.isFavorite; + }); + + // Since we flipped the favourite state already, we can use the flipped + // state to decide which API call to make + final newUserData = widget.item.userData!.isFavorite + ? await _jellyfinApiHelper.addFavourite(widget.item.id) + : await _jellyfinApiHelper.removeFavourite(widget.item.id); + + if (!mounted) return; + + setState(() { + widget.item.userData = newUserData; + }); + } catch (e) { + setState(() { + widget.item.userData!.isFavorite = !widget.item.userData!.isFavorite; + }); + errorSnackbar(e, context); + } + } + + final listTile = StreamBuilder( + stream: mediaStateStream, + builder: (context, snapshot) { + // I think past me did this check directly from the JSON for + // performance. It works for now, apologies if you're debugging it + // years in the future. + final isCurrentlyPlaying = + snapshot.data?.mediaItem?.extras?["itemJson"]["Id"] == + widget.item.id && + snapshot.data?.mediaItem?.extras?["itemJson"]["AlbumId"] == + widget.parentId; + + return ListTile( + leading: AlbumImage(item: widget.item), + title: RichText( + text: TextSpan( + children: [ + // third condition checks if the item is viewed from its album (instead of e.g. a playlist) + // same horrible check as in canGoToAlbum in GestureDetector below + if (widget.item.indexNumber != null && + !widget.isSong && + widget.item.albumId == widget.parentId) + TextSpan( + text: "${widget.item.indexNumber}. ", + style: + TextStyle(color: Theme.of(context).disabledColor)), + TextSpan( + text: widget.item.name ?? + AppLocalizations.of(context)!.unknownName, + style: TextStyle( + color: isCurrentlyPlaying + ? Theme.of(context).colorScheme.secondary + : null, + ), + ), + ], + style: Theme.of(context).textTheme.titleMedium, + ), + ), + subtitle: RichText( + text: TextSpan( + children: [ + WidgetSpan( + child: Transform.translate( + offset: const Offset(-3, 0), + child: DownloadedIndicator( + item: widget.item, + size: + Theme.of(context).textTheme.bodyMedium!.fontSize! + + 3, + ), + ), + alignment: PlaceholderAlignment.top, + ), + TextSpan( + text: printDuration(Duration( + microseconds: (widget.item.runTimeTicks == null + ? 0 + : widget.item.runTimeTicks! ~/ 10))), + style: TextStyle( + color: Theme.of(context) + .textTheme + .bodyMedium + ?.color + ?.withOpacity(0.7)), + ), + if (widget.showArtists) + TextSpan( + text: + " · ${processArtist(widget.item.artists?.join(", ") ?? widget.item.albumArtist, context)}", + style: TextStyle(color: Theme.of(context).disabledColor), + ) + ], + ), + overflow: TextOverflow.ellipsis, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isCurrentlyPlaying && + (snapshot.data?.playbackState.playing ?? false)) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: MiniMusicVisualizer( + color: Theme.of(context).colorScheme.secondary, + width: 4, + height: 15, + ), + ), + FavoriteButton( + item: widget.item, + onlyIfFav: true, + ), + ], + ), + onTap: () { + _audioServiceHelper.replaceQueueWithItem( + itemList: widget.children ?? [widget.item], + initialIndex: widget.index ?? 0, + ); + }, + ); + }); + + return GestureDetector( + onLongPressStart: (details) async { + Feedback.forLongPress(context); + + // This horrible check does 2 things: + // - Checks if the item's album is not the same as the parent item + // that created the widget. The ids will be different if the + // SongListTile is in a playlist, but they will be the same if viewed + // in the item's album. We don't want to show this menu item if we're + // already in the item's album. + // + // - Checks if the album is downloaded if in offline mode. If we're in + // offline mode, we need the album to actually be downloaded to show + // its metadata. This function also checks if widget.item.parentId is + // null. + final canGoToAlbum = widget.item.albumId != widget.parentId && + _isAlbumDownloadedIfOffline(widget.item.parentId); + + // Some options are disabled in offline mode + final isOffline = FinampSettingsHelper.finampSettings.isOffline; + + final selection = await showMenu( + context: context, + position: RelativeRect.fromLTRB( + details.globalPosition.dx, + details.globalPosition.dy, + screenSize.width - details.globalPosition.dx, + screenSize.height - details.globalPosition.dy, + ), + items: [ + if (_audioServiceHelper.hasQueueItems()) ...[ + PopupMenuItem( + value: SongListTileMenuItems.addToQueue, + child: ListTile( + leading: const Icon(Icons.queue_music), + title: Text(AppLocalizations.of(context)!.addToQueue), + ), + ), + PopupMenuItem( + value: SongListTileMenuItems.playNext, + child: ListTile( + leading: const Icon(Icons.queue_music), + title: Text(AppLocalizations.of(context)!.playNext), + ), + ), + ], + PopupMenuItem( + value: SongListTileMenuItems.replaceQueueWithItem, + child: ListTile( + leading: const Icon(Icons.play_circle), + title: Text(AppLocalizations.of(context)!.replaceQueue), + ), + ), + if (widget.isInPlaylist) + PopupMenuItem( + enabled: !isOffline, + value: SongListTileMenuItems.addToPlaylist, + child: ListTile( + leading: const Icon(Icons.playlist_add), + title: Text(AppLocalizations.of(context)!.addToPlaylistTitle), + enabled: !isOffline, + ), + ), + widget.isInPlaylist + ? PopupMenuItem( + enabled: !isOffline, + value: SongListTileMenuItems.removeFromPlaylist, + child: ListTile( + leading: const Icon(Icons.playlist_remove), + title: Text(AppLocalizations.of(context)! + .removeFromPlaylistTitle), + enabled: !isOffline && widget.parentId != null, + ), + ) + : PopupMenuItem( + enabled: !isOffline, + value: SongListTileMenuItems.addToPlaylist, + child: ListTile( + leading: const Icon(Icons.playlist_add), + title: Text( + AppLocalizations.of(context)!.addToPlaylistTitle), + enabled: !isOffline, + ), + ), + PopupMenuItem( + enabled: !isOffline, + value: SongListTileMenuItems.instantMix, + child: ListTile( + leading: const Icon(Icons.explore), + title: Text(AppLocalizations.of(context)!.instantMix), + enabled: !isOffline, + ), + ), + PopupMenuItem( + enabled: canGoToAlbum, + value: SongListTileMenuItems.goToAlbum, + child: ListTile( + leading: const Icon(Icons.album), + title: Text(AppLocalizations.of(context)!.goToAlbum), + enabled: canGoToAlbum, + ), + ), + widget.item.userData!.isFavorite + ? PopupMenuItem( + value: SongListTileMenuItems.removeFavourite, + child: ListTile( + leading: const Icon(Icons.favorite_border), + title: + Text(AppLocalizations.of(context)!.removeFavourite), + ), + ) + : PopupMenuItem( + value: SongListTileMenuItems.addFavourite, + child: ListTile( + leading: const Icon(Icons.favorite), + title: Text(AppLocalizations.of(context)!.addFavourite), + ), + ), + ], + ); + + if (!mounted) return; + + switch (selection) { + case SongListTileMenuItems.addToQueue: + await _audioServiceHelper.addQueueItems([widget.item]); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.addedToQueue), + )); + break; + + case SongListTileMenuItems.playNext: + await _audioServiceHelper.insertQueueItemsNext([widget.item]); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.insertedIntoQueue), + )); + break; + + case SongListTileMenuItems.replaceQueueWithItem: + await _audioServiceHelper + .replaceQueueWithItem(itemList: [widget.item]); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.queueReplaced), + )); + break; + + case SongListTileMenuItems.addToPlaylist: + Navigator.of(context).pushNamed(AddToPlaylistScreen.routeName, + arguments: widget.item.id); + break; + + case SongListTileMenuItems.removeFromPlaylist: + try { + await _jellyfinApiHelper.removeItemsFromPlaylist( + playlistId: widget.parentId!, + entryIds: [widget.item.playlistItemId!]); + + if (!mounted) return; + + await _jellyfinApiHelper.getItems( + parentItem: + await _jellyfinApiHelper.getItemById(widget.item.parentId!), + sortBy: "ParentIndexNumber,IndexNumber,SortName", + includeItemTypes: "Audio", + isGenres: false, + ); + + if (!mounted) return; + + if (widget.onDelete != null) widget.onDelete!(); + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: + Text(AppLocalizations.of(context)!.removedFromPlaylist), + )); + } catch (e) { + errorSnackbar(e, context); + } + break; + + case SongListTileMenuItems.instantMix: + await _audioServiceHelper.startInstantMixForItem(widget.item); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.startingInstantMix), + )); + break; + case SongListTileMenuItems.goToAlbum: + late BaseItemDto album; + if (FinampSettingsHelper.finampSettings.isOffline) { + // If offline, load the album's BaseItemDto from DownloadHelper. + final downloadsHelper = GetIt.instance(); + + // downloadedParent won't be null here since the menu item already + // checks if the DownloadedParent exists. + album = downloadsHelper + .getDownloadedParent(widget.item.parentId!)! + .item; + } else { + // If online, get the album's BaseItemDto from the server. + try { + album = + await _jellyfinApiHelper.getItemById(widget.item.parentId!); + } catch (e) { + errorSnackbar(e, context); + break; + } + } + + if (!mounted) return; + + Navigator.of(context) + .pushNamed(AlbumScreen.routeName, arguments: album); + break; + case SongListTileMenuItems.addFavourite: + case SongListTileMenuItems.removeFavourite: + await setFavourite(); + break; + case null: + break; + } + }, + child: widget.isSong + ? listTile + : Dismissible( + key: Key(widget.index.toString()), + direction: FinampSettingsHelper.finampSettings.disableGesture + ? DismissDirection.none + : DismissDirection.horizontal, + background: Container( + color: Theme.of(context).colorScheme.secondaryContainer, + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + children: [ + AspectRatio( + aspectRatio: 1, + child: FittedBox( + fit: BoxFit.fitHeight, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Icon( + Icons.queue_music, + color: Theme.of(context) + .colorScheme + .onSecondaryContainer, + ), + ), + ), + ) + ], + ), + ), + ), + confirmDismiss: (direction) async { + if (!FinampSettingsHelper.finampSettings.swipeInsertQueueNext) { + await _audioServiceHelper.addQueueItems([widget.item]); + } else { + await _audioServiceHelper.insertQueueItemsNext([widget.item]); + } + + if (!mounted) return false; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text( + FinampSettingsHelper.finampSettings.swipeInsertQueueNext + ? AppLocalizations.of(context)!.insertedIntoQueue + : AppLocalizations.of(context)!.addedToQueue), + )); + + return false; + }, + child: listTile, + ), + ); + } +} + +/// If offline, check if an album is downloaded. Always returns true if online. +/// Returns false if albumId is null. +bool _isAlbumDownloadedIfOffline(String? albumId) { + if (albumId == null) { + return false; + } else if (FinampSettingsHelper.finampSettings.isOffline) { + final downloadsHelper = GetIt.instance(); + return downloadsHelper.isAlbumDownloaded(albumId); + } else { + return true; + } +} diff --git a/lib/components/AlbumScreen/sync_album_or_playlist_button.dart b/lib/components/AlbumScreen/sync_album_or_playlist_button.dart new file mode 100644 index 0000000..2a2553d --- /dev/null +++ b/lib/components/AlbumScreen/sync_album_or_playlist_button.dart @@ -0,0 +1,52 @@ +import 'package:finamp/models/jellyfin_models.dart'; +import 'package:finamp/services/downloads_helper.dart'; +import 'package:finamp/services/sync_helper.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logging/logging.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class SyncAlbumOrPlaylistButton extends StatefulWidget { + const SyncAlbumOrPlaylistButton({ + Key? key, + required this.parent, + required this.items, + }) : super(key: key); + + final BaseItemDto parent; + final List items; + @override + State createState() => + _SyncAlbumOrPlaylistButtonState(); +} +class _SyncAlbumOrPlaylistButtonState + extends State { + final _syncLogger = Logger("SyncPlaylistButton"); + final _downloadHelper = GetIt.instance(); + bool isAlbumDownloaded = false; + + + void syncAlbumOrPlaylist(BuildContext context) async { + _syncLogger.info("Syncing playlist"); + + var syncHelper = DownloadsSyncHelper(_syncLogger); + syncHelper.sync(context, widget.parent, widget.items); + setState(() { + isAlbumDownloaded = _downloadHelper.isAlbumDownloaded(widget.parent.id); + }); + } + + @override + Widget build(BuildContext context) { + isAlbumDownloaded = _downloadHelper.isAlbumDownloaded(widget.parent.id); + return IconButton( + tooltip: isAlbumDownloaded + ? AppLocalizations.of(context)!.sync + : AppLocalizations.of(context)!.download, + onPressed: () => syncAlbumOrPlaylist(context), + icon: + isAlbumDownloaded ? + const Icon(Icons.sync) : + const Icon(Icons.download)); + } +} diff --git a/lib/components/ArtistScreen/artist_download_button.dart b/lib/components/ArtistScreen/artist_download_button.dart new file mode 100644 index 0000000..c8f9734 --- /dev/null +++ b/lib/components/ArtistScreen/artist_download_button.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; +import 'package:hive/hive.dart'; + +import '../../models/jellyfin_models.dart'; +import '../../models/finamp_models.dart'; +import '../../services/finamp_settings_helper.dart'; +import '../../services/finamp_user_helper.dart'; +import '../../services/jellyfin_api_helper.dart'; +import '../../services/downloads_helper.dart'; +import '../AlbumScreen/download_dialog.dart'; +import '../confirmation_prompt_dialog.dart'; +import '../error_snackbar.dart'; + +class ArtistDownloadButton extends StatefulWidget { + const ArtistDownloadButton({ + Key? key, + required this.artist, + }) : super(key: key); + + final BaseItemDto artist; + + @override + State createState() => _ArtistDownloadButtonState(); +} + +class _ArtistDownloadButtonState extends State { + static const _disabledButton = IconButton( + icon: Icon(Icons.download), + onPressed: null, + ); + Future?>? _artistDownloadButtonFuture; + + final _jellyfinApiHelper = GetIt.instance(); + final _downloadsHelper = GetIt.instance(); + final _finampUserHelper = GetIt.instance(); + + List _getUndownloadedAlbums(List albums) { + return albums + .where((element) => !_downloadsHelper.isAlbumDownloaded(element.id)) + .toList(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (context, box, _) { + final isOffline = box.get("FinampSettings")?.isOffline ?? false; + bool deleteAlbums = false; + + if (isOffline) { + return _disabledButton; + } else { + // We only want to get album data if we're online + _artistDownloadButtonFuture ??= _jellyfinApiHelper.getItems( + parentItem: widget.artist, + includeItemTypes: "MusicAlbum", + isGenres: false, + ); + return FutureBuilder?>( + future: _artistDownloadButtonFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + final undownloadedAlbums = + _getUndownloadedAlbums(snapshot.data!); + deleteAlbums = undownloadedAlbums.isEmpty; + + return IconButton( + tooltip: AppLocalizations.of(context)! + .downloadArtist(widget.artist.name ?? "Unknown Artist"), + icon: deleteAlbums + ? const Icon(Icons.delete) + : const Icon(Icons.download), + onPressed:() async { + if (deleteAlbums) { + showDialog( + context: context, + builder: (context) => ConfirmationPromptDialog( + promptText: AppLocalizations.of(context)! + .deleteDownloadsPrompt( + widget.artist.name ?? "", + widget.artist.type == "MusicArtist" ? "artist" : "genre"), + confirmButtonText: AppLocalizations.of(context)! + .deleteDownloadsConfirmButtonText, + abortButtonText: AppLocalizations.of(context)! + .deleteDownloadsAbortButtonText, + onConfirmed: () async { + try { + final deleteFutures = snapshot.data!.map((e) => + _downloadsHelper.deleteParentAndChildDownloads( + jellyfinItemIds: _downloadsHelper + .getDownloadedParent(e.id)! + .downloadedChildren + .keys + .toList(), + deletedFor: e.id)); + await Future.wait(deleteFutures); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Downloads deleted."))); + final undownloadedAlbums = + _getUndownloadedAlbums(snapshot.data!); + setState(() { + deleteAlbums = undownloadedAlbums.isEmpty; + }); + } catch (error) { + errorSnackbar(error, context); + } + }, + onAborted: () {}, + ), + ); + } else { + List?>> albumInfoFutures = []; + for (var element in undownloadedAlbums) { + albumInfoFutures.add(_jellyfinApiHelper.getItems( + parentItem: element, + sortBy: "SortName", + includeItemTypes: "Audio", + isGenres: false, + )); + } + + List?> albumInfo; + + try { + albumInfo = await Future.wait(albumInfoFutures); + } catch (e) { + errorSnackbar(e, context); + return; + } + + await showDialog( + context: context, + builder: (context) => DownloadDialog( + parents: undownloadedAlbums, + // getItems returns null so we have to null check + // each element + items: albumInfo.map((e) => e!).toList(), + viewId: _finampUserHelper.currentUser!.currentViewId!, + ), + ); + } + // We call a setState so that the downloaded albums are + // checked again (so that the download icon turns into a + // delete icon and vice-versa) + setState(() {}); + }, + ); + } else if (snapshot.hasError) { + errorSnackbar(snapshot.error, context); + return _disabledButton; + } else { + return _disabledButton; + } + }, + ); + } + }, + ); + } +} diff --git a/lib/components/ArtistScreen/artist_play_button.dart b/lib/components/ArtistScreen/artist_play_button.dart new file mode 100644 index 0000000..4ac7caf --- /dev/null +++ b/lib/components/ArtistScreen/artist_play_button.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:hive/hive.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + + +import '../../models/jellyfin_models.dart'; +import '../../models/finamp_models.dart'; +import '../../services/jellyfin_api_helper.dart'; +import '../../services/audio_service_helper.dart'; +import '../../services/finamp_settings_helper.dart'; +import '../../services/downloads_helper.dart'; + +class ArtistPlayButton extends StatefulWidget { + const ArtistPlayButton({ + Key? key, + required this.artist, + }) : super(key: key); + + final BaseItemDto artist; + + + @override + State createState() => _ArtistPlayButtonState(); +} + +class _ArtistPlayButtonState extends State { + static const _disabledButton = IconButton( + onPressed: null, + icon: Icon(Icons.play_arrow) + ); + Future?>? artistPlayButtonFuture; + + final _jellyfinApiHelper = GetIt.instance(); + final _audioServiceHelper = GetIt.instance(); + + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (context, box, _) { + final isOffline = box.get("FinampSettings")?.isOffline ?? false; + + if (isOffline) { + final downloadsHelper = GetIt.instance(); + + final ListartistsSongs = []; + + for (DownloadedSong item in downloadsHelper.downloadedItems) { + if (item.song.albumArtist == widget.artist.name) { + artistsSongs.add(item.song); + } + } + + // We have to sort by hand in offline mode because a downloadedParent for artists hasn't been implemented + Map> groupedSongs = {}; + for (BaseItemDto song in artistsSongs) { + groupedSongs.putIfAbsent((song.albumId ?? 'unknown'), () => []); + groupedSongs[song.albumId]!.add(song); + } + + final List sortedSongs = []; + groupedSongs.forEach((album, albumSongs) { + albumSongs.sort((a, b) => (a.indexNumber ?? 0).compareTo(b.indexNumber ?? 0)); + sortedSongs.addAll(albumSongs); + }); + + return IconButton( + tooltip: AppLocalizations.of(context)! + .playArtist(widget.artist.name ?? "Unknown Artist"), + onPressed: () async { + await _audioServiceHelper + .replaceQueueWithItem(itemList: sortedSongs); + }, + icon: const Icon(Icons.play_arrow), + ); + } else { + artistPlayButtonFuture ??= _jellyfinApiHelper.getItems( + parentItem: widget.artist, + includeItemTypes: "Audio", + sortBy: 'PremiereDate,Album,SortName', + isGenres: false, + ); + + return FutureBuilder?>( + future: artistPlayButtonFuture, + builder: (context, snapshot) { + if (snapshot.hasData){ + final List items = snapshot.data!; + + return IconButton( + tooltip: AppLocalizations.of(context)! + .playArtist(widget.artist.name ?? "Unknown Artist"), + onPressed: () async { + await _audioServiceHelper + .replaceQueueWithItem(itemList: items); + }, + icon: const Icon(Icons.play_arrow), + ); + } else { + return _disabledButton; + } + }, + ); + } + }, + ); + } +} diff --git a/lib/components/ArtistScreen/artist_shuffle_button.dart b/lib/components/ArtistScreen/artist_shuffle_button.dart new file mode 100644 index 0000000..28d1f31 --- /dev/null +++ b/lib/components/ArtistScreen/artist_shuffle_button.dart @@ -0,0 +1,99 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:hive/hive.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + + +import '../../models/jellyfin_models.dart'; +import '../../models/finamp_models.dart'; +import '../../services/jellyfin_api_helper.dart'; +import '../../services/audio_service_helper.dart'; +import '../../services/finamp_settings_helper.dart'; +import '../../services/downloads_helper.dart'; + +class ArtistShuffleButton extends StatefulWidget { + const ArtistShuffleButton({ + Key? key, + required this.artist, + }) : super(key: key); + + final BaseItemDto artist; + + + @override + State createState() => _ArtistShuffleButtonState(); +} + +class _ArtistShuffleButtonState extends State { + static const _disabledButton = IconButton( + onPressed: null, + icon: Icon(Icons.play_arrow) + ); + Future?>? artistShuffleButtonFuture; + + final _jellyfinApiHelper = GetIt.instance(); + final _audioServiceHelper = GetIt.instance(); + + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (context, box, _) { + final isOffline = box.get("FinampSettings")?.isOffline ?? false; + + if (isOffline) { + final downloadsHelper = GetIt.instance(); + + final ListartistsSongs = []; + + for (DownloadedSong item in downloadsHelper.downloadedItems) { + if (item.song.albumArtist == widget.artist.name) { + artistsSongs.add(item.song); + } + } + + return IconButton( + tooltip: AppLocalizations.of(context)! + .shuffleArtist(widget.artist.name ?? "Unknown Artist"), + onPressed: () async { + await _audioServiceHelper + .replaceQueueWithItem(itemList: artistsSongs, shuffle: true); + }, + icon: const Icon(Icons.shuffle), + ); + } else { + artistShuffleButtonFuture ??= _jellyfinApiHelper.getItems( + parentItem: widget.artist, + includeItemTypes: "Audio", + sortBy: 'PremiereDate,Album,SortName', + isGenres: false, + ); + + return FutureBuilder?>( + future: artistShuffleButtonFuture, + builder: (context, snapshot) { + if (snapshot.hasData){ + final List items = snapshot.data!; + + return IconButton( + tooltip: AppLocalizations.of(context)! + .shuffleArtist(widget.artist.name ?? "Unknown Artist"), + onPressed: () async { + await _audioServiceHelper + .replaceQueueWithItem(itemList: items, shuffle: true, initialIndex: Random().nextInt(items.length)); + }, + icon: const Icon(Icons.shuffle), + ); + } else { + return _disabledButton; + } + }, + ); + } + }, + ); + } +} diff --git a/lib/components/AudioServiceSettingsScreen/buffer_duration_list_tile.dart b/lib/components/AudioServiceSettingsScreen/buffer_duration_list_tile.dart new file mode 100644 index 0000000..47de14c --- /dev/null +++ b/lib/components/AudioServiceSettingsScreen/buffer_duration_list_tile.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../../services/finamp_settings_helper.dart'; + +class BufferDurationListTile extends StatefulWidget { + const BufferDurationListTile({super.key}); + + @override + State createState() => _BufferDurationListTileState(); +} + +class _BufferDurationListTileState extends State { + final _controller = TextEditingController( + text: + FinampSettingsHelper.finampSettings.bufferDurationSeconds.toString()); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(AppLocalizations.of(context)!.bufferDuration), + subtitle: Text(AppLocalizations.of(context)!.bufferDurationSubtitle), + trailing: SizedBox( + width: 50 * MediaQuery.of(context).textScaleFactor, + child: TextField( + controller: _controller, + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + onChanged: (value) { + final valueInt = int.tryParse(value); + + if (valueInt != null && !valueInt.isNegative) { + FinampSettingsHelper.setBufferDuration( + Duration(seconds: valueInt)); + } + }, + ), + ), + ); + } +} diff --git a/lib/components/AudioServiceSettingsScreen/song_shuffle_item_count_editor.dart b/lib/components/AudioServiceSettingsScreen/song_shuffle_item_count_editor.dart new file mode 100644 index 0000000..19d08c6 --- /dev/null +++ b/lib/components/AudioServiceSettingsScreen/song_shuffle_item_count_editor.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../../services/finamp_settings_helper.dart'; + +class SongShuffleItemCountEditor extends StatefulWidget { + const SongShuffleItemCountEditor({Key? key}) : super(key: key); + + @override + State createState() => + _SongShuffleItemCountEditorState(); +} + +class _SongShuffleItemCountEditorState + extends State { + final _controller = TextEditingController( + text: + FinampSettingsHelper.finampSettings.songShuffleItemCount.toString()); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(AppLocalizations.of(context)!.shuffleAllSongCount), + subtitle: Text(AppLocalizations.of(context)!.shuffleAllSongCountSubtitle), + trailing: SizedBox( + width: 50 * MediaQuery.of(context).textScaleFactor, + child: TextField( + controller: _controller, + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + onChanged: (value) { + final valueInt = int.tryParse(value); + + if (valueInt != null) { + FinampSettingsHelper.setSongShuffleItemCount(valueInt); + } + }, + ), + ), + ); + } +} diff --git a/lib/components/AudioServiceSettingsScreen/stop_foreground_selector.dart b/lib/components/AudioServiceSettingsScreen/stop_foreground_selector.dart new file mode 100644 index 0000000..4b81f80 --- /dev/null +++ b/lib/components/AudioServiceSettingsScreen/stop_foreground_selector.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hive/hive.dart'; + +import '../../services/finamp_settings_helper.dart'; +import '../../models/finamp_models.dart'; + +class StopForegroundSelector extends StatelessWidget { + const StopForegroundSelector({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (_, box, __) { + return SwitchListTile.adaptive( + title: + Text(AppLocalizations.of(context)!.enterLowPriorityStateOnPause), + subtitle: Text(AppLocalizations.of(context)! + .enterLowPriorityStateOnPauseSubtitle), + value: + FinampSettingsHelper.finampSettings.androidStopForegroundOnPause, + onChanged: (value) => + FinampSettingsHelper.setAndroidStopForegroundOnPause(value), + ); + }, + ); + } +} diff --git a/lib/components/DownloadLocationSettingsScreen/download_location_delete_dialog.dart b/lib/components/DownloadLocationSettingsScreen/download_location_delete_dialog.dart new file mode 100644 index 0000000..60383a2 --- /dev/null +++ b/lib/components/DownloadLocationSettingsScreen/download_location_delete_dialog.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import '../../services/finamp_settings_helper.dart'; + +class DownloadLocationDeleteDialog extends StatelessWidget { + const DownloadLocationDeleteDialog({ + Key? key, + required this.id, + }) : super(key: key); + + final String id; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text("Are you sure?"), + content: const Text( + "Deleting a download location doesn't actually delete any downloads. It just removes the menu entry."), + actions: [ + TextButton( + child: const Text("CANCEL"), + onPressed: () => Navigator.of(context).pop(), + ), + TextButton( + child: const Text("DELETE"), + onPressed: () { + FinampSettingsHelper.deleteDownloadLocation(id); + Navigator.of(context).pop(); + }, + ), + ], + ); + } +} diff --git a/lib/components/DownloadLocationSettingsScreen/download_location_list.dart b/lib/components/DownloadLocationSettingsScreen/download_location_list.dart new file mode 100644 index 0000000..3a5d2c6 --- /dev/null +++ b/lib/components/DownloadLocationSettingsScreen/download_location_list.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; + +import '../../models/finamp_models.dart'; +import '../../services/finamp_settings_helper.dart'; +import 'download_location_list_tile.dart'; + +class DownloadLocationList extends StatefulWidget { + const DownloadLocationList({Key? key}) : super(key: key); + + @override + State createState() => _DownloadLocationListState(); +} + +class _DownloadLocationListState extends State { + late Iterable downloadLocationsIterable; + + @override + void initState() { + super.initState(); + downloadLocationsIterable = + FinampSettingsHelper.finampSettings.downloadLocationsMap.values; + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (context, box, child) { + return ListView.builder( + itemCount: downloadLocationsIterable.length, + itemBuilder: (context, index) { + return DownloadLocationListTile( + downloadLocation: downloadLocationsIterable.elementAt(index), + ); + }, + ); + }, + ); + } +} diff --git a/lib/components/DownloadLocationSettingsScreen/download_location_list_tile.dart b/lib/components/DownloadLocationSettingsScreen/download_location_list_tile.dart new file mode 100644 index 0000000..351e491 --- /dev/null +++ b/lib/components/DownloadLocationSettingsScreen/download_location_list_tile.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +import '../../models/finamp_models.dart'; +import 'download_location_delete_dialog.dart'; + +class DownloadLocationListTile extends StatelessWidget { + const DownloadLocationListTile({ + Key? key, + required this.downloadLocation, + }) : super(key: key); + + final DownloadLocation downloadLocation; + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(downloadLocation.name), + subtitle: Text( + downloadLocation.path, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: IconButton( + icon: const Icon(Icons.delete), + onPressed: downloadLocation.deletable + ? () => showDialog( + context: context, + builder: (context) => DownloadLocationDeleteDialog( + id: downloadLocation.id, + ), + ) + : null, + ), + ); + } +} diff --git a/lib/components/DownloadsErrorScreen/download_error_list.dart b/lib/components/DownloadsErrorScreen/download_error_list.dart new file mode 100644 index 0000000..1af8e44 --- /dev/null +++ b/lib/components/DownloadsErrorScreen/download_error_list.dart @@ -0,0 +1,82 @@ + +import 'package:flutter/material.dart'; +import 'package:flutter_downloader/flutter_downloader.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; + +import '../../services/downloads_helper.dart'; +import '../error_snackbar.dart'; +import 'download_error_list_tile.dart'; + +class DownloadErrorList extends StatefulWidget { + const DownloadErrorList({Key? key}) : super(key: key); + + @override + State createState() => _DownloadErrorListState(); +} + +class _DownloadErrorListState extends State { + List? loadedDownloadTasks; + + late Future?> downloadErrorListFuture; + DownloadsHelper downloadsHelper = GetIt.instance(); + + @override + void initState() { + super.initState(); + downloadErrorListFuture = + downloadsHelper.getDownloadsWithStatus(DownloadTaskStatus.failed); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder?>( + future: downloadErrorListFuture, + builder: (context, snapshot) { + loadedDownloadTasks = snapshot.data; + if (snapshot.hasData) { + if (snapshot.data!.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check, + size: 64, + // Inactive icons have an opacity of 50% with dark theme and 38% + // with bright theme + // https://material.io/design/iconography/system-icons.html#color + color: Theme.of(context).iconTheme.color?.withOpacity( + Theme.of(context).brightness == Brightness.light + ? 0.38 + : 0.5)), + const Padding(padding: EdgeInsets.all(8.0)), + Text(AppLocalizations.of(context)!.noErrors), + ], + ), + ); + } else { + return ListView.builder( + itemCount: snapshot.data!.length, + itemBuilder: (context, index) { + return DownloadErrorListTile( + downloadTask: snapshot.data![index]); + }, + ); + } + } else if (snapshot.hasError) { + errorSnackbar(snapshot.error, context); + return Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text(AppLocalizations.of(context)!.errorScreenError), + ), + ); + } else { + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + } + }, + ); + } +} diff --git a/lib/components/DownloadsErrorScreen/download_error_list_tile.dart b/lib/components/DownloadsErrorScreen/download_error_list_tile.dart new file mode 100644 index 0000000..d29b425 --- /dev/null +++ b/lib/components/DownloadsErrorScreen/download_error_list_tile.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_downloader/flutter_downloader.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; + +import '../../models/finamp_models.dart'; +import '../../services/downloads_helper.dart'; +import '../../services/process_artist.dart'; +import '../album_image.dart'; + +class DownloadErrorListTile extends StatelessWidget { + const DownloadErrorListTile({Key? key, required this.downloadTask}) + : super(key: key); + + final DownloadTask downloadTask; + + @override + Widget build(BuildContext context) { + DownloadsHelper downloadsHelper = GetIt.instance(); + DownloadedSong? downloadedSong = + downloadsHelper.getJellyfinItemFromDownloadId(downloadTask.taskId); + + if (downloadedSong == null) { + return ListTile( + title: Text(downloadTask.taskId), + subtitle: + Text(AppLocalizations.of(context)!.failedToGetSongFromDownloadId), + ); + } + + return ListTile( + leading: AlbumImage(item: downloadedSong.song), + title: Text(downloadedSong.song.name == null + ? AppLocalizations.of(context)!.unknownName + : downloadedSong.song.name!), + subtitle: Text(processArtist(downloadedSong.song.albumArtist, context)), + // trailing: IconButton( + // icon: Icon(Icons.refresh), + // onPressed: () {}, + // tooltip: "Retry", + // ), + ); + } +} diff --git a/lib/components/DownloadsScreen/album_file_size.dart b/lib/components/DownloadsScreen/album_file_size.dart new file mode 100644 index 0000000..06c998a --- /dev/null +++ b/lib/components/DownloadsScreen/album_file_size.dart @@ -0,0 +1,30 @@ +import 'package:file_sizes/file_sizes.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; + +import '../../models/finamp_models.dart'; +import '../../services/downloads_helper.dart'; + +class AlbumFileSize extends StatelessWidget { + const AlbumFileSize({Key? key, required this.downloadedParent}) + : super(key: key); + + final DownloadedParent downloadedParent; + + @override + Widget build(BuildContext context) { + DownloadsHelper downloadsHelper = GetIt.instance(); + int totalSize = 0; + + for (final item in downloadedParent.downloadedChildren.values) { + DownloadedSong? downloadedSong = + downloadsHelper.getDownloadedSong(item.id); + + if (downloadedSong?.mediaSourceInfo.size != null) { + totalSize += downloadedSong!.mediaSourceInfo.size!; + } + } + + return Text(FileSize.getSize(totalSize)); + } +} diff --git a/lib/components/DownloadsScreen/current_downloads_list.dart b/lib/components/DownloadsScreen/current_downloads_list.dart new file mode 100644 index 0000000..77290a0 --- /dev/null +++ b/lib/components/DownloadsScreen/current_downloads_list.dart @@ -0,0 +1,108 @@ +import 'dart:isolate'; +import 'dart:ui'; + +import 'package:finamp/services/downloads_helper.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_downloader/flutter_downloader.dart'; +import 'package:get_it/get_it.dart'; + +import '../../models/finamp_models.dart'; +import '../error_snackbar.dart'; +import '../album_image.dart'; + +class CurrentDownloadsList extends StatefulWidget { + const CurrentDownloadsList({Key? key}) : super(key: key); + + @override + State createState() => _CurrentDownloadsListState(); +} + +class _CurrentDownloadsListState extends State { + final ReceivePort _port = ReceivePort(); + final DownloadsHelper _downloadsHelper = GetIt.instance(); + + @override + void initState() { + super.initState(); + + IsolateNameServer.registerPortWithName( + _port.sendPort, 'downloader_send_port'); + _port.listen((dynamic data) { + setState(() {}); + }); + + FlutterDownloader.registerCallback(downloadCallback); + } + + @override + void dispose() { + IsolateNameServer.removePortNameMapping('downloader_send_port'); + super.dispose(); + } + + // https://github.com/fluttercommunity/flutter_downloader/issues/629 + @pragma('vm:entry-point') + static void downloadCallback(String id, int status, int progress) { + final SendPort? send = + IsolateNameServer.lookupPortByName('downloader_send_port'); + send?.send([id, status, progress]); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder?>( + future: _downloadsHelper.getIncompleteDownloads(), + builder: (context, snapshot) { + if (snapshot.hasData) { + return SliverList( + delegate: + SliverChildBuilderDelegate((BuildContext context, int index) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: CurrentDownloadListTile( + downloadTask: snapshot.data![index], + )); + }, childCount: snapshot.data!.length), + ); + } else if (snapshot.hasError) { + errorSnackbar(snapshot.error, context); + return SliverList( + delegate: SliverChildListDelegate([ + const Text("An error occured while getting current downloads") + ])); + } else { + return SliverList(delegate: SliverChildListDelegate([])); + } + }, + ); + } +} + +class CurrentDownloadListTile extends StatelessWidget { + const CurrentDownloadListTile({Key? key, required this.downloadTask}) + : super(key: key); + + final DownloadTask downloadTask; + + @override + Widget build(BuildContext context) { + DownloadsHelper downloadsHelper = GetIt.instance(); + DownloadedSong? item = + downloadsHelper.getJellyfinItemFromDownloadId(downloadTask.taskId); + + return Stack( + alignment: Alignment.bottomCenter, + children: [ + LinearProgressIndicator( + value: downloadTask.progress / 100, + backgroundColor: Colors.transparent, + ), + ListTile( + leading: AlbumImage(item: item?.song), + title: Text(item?.song.name ?? "???"), + subtitle: Text(item?.song.albumArtist ?? "???"), + ) + ], + ); + } +} diff --git a/lib/components/DownloadsScreen/download_error_screen_button.dart b/lib/components/DownloadsScreen/download_error_screen_button.dart new file mode 100644 index 0000000..c4ab150 --- /dev/null +++ b/lib/components/DownloadsScreen/download_error_screen_button.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_downloader/flutter_downloader.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; + +import '../../screens/downloads_error_screen.dart'; +import '../../services/downloads_helper.dart'; + +class DownloadErrorScreenButton extends StatefulWidget { + const DownloadErrorScreenButton({Key? key}) : super(key: key); + + @override + State createState() => + _DownloadErrorScreenButtonState(); +} + +class _DownloadErrorScreenButtonState extends State { + final _downloadsHelper = GetIt.instance(); + late Future?> downloadErrorScreenButtonFuture; + + @override + void initState() { + super.initState(); + downloadErrorScreenButtonFuture = + _downloadsHelper.getDownloadsWithStatus(DownloadTaskStatus.failed); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder?>( + future: downloadErrorScreenButtonFuture, + builder: (context, snapshot) { + return IconButton( + onPressed: () => + Navigator.of(context).pushNamed(DownloadsErrorScreen.routeName), + icon: Icon( + Icons.error, + color: snapshot.data?.isNotEmpty ?? false + ? Theme.of(context).colorScheme.error + : null, + ), + tooltip: AppLocalizations.of(context)!.downloadErrors, + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/components/DownloadsScreen/download_missing_images_button.dart b/lib/components/DownloadsScreen/download_missing_images_button.dart new file mode 100644 index 0000000..0c8f4cf --- /dev/null +++ b/lib/components/DownloadsScreen/download_missing_images_button.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; + +import '../../services/downloads_helper.dart'; +import '../../services/finamp_settings_helper.dart'; + +class DownloadMissingImagesButton extends StatefulWidget { + const DownloadMissingImagesButton({Key? key}) : super(key: key); + + @override + State createState() => + _DownloadMissingImagesButtonState(); +} + +class _DownloadMissingImagesButtonState + extends State { + final _downloadsHelper = GetIt.instance(); + + // The user shouldn't be able to check for missing downloads while offline + bool _enabled = !FinampSettingsHelper.finampSettings.isOffline; + + void downloadImages() async { + // We don't want two checks to happen at once, so we disable the + // button while a check is running (it shouldn't take long enough + // for the user to be able to press twice, but you never know) + setState(() { + _enabled = false; + }); + + final imagesDownloaded = await _downloadsHelper.downloadMissingImages(); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)! + .downloadedMissingImages(imagesDownloaded)), + )); + + setState(() { + _enabled = true; + }); + } + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: _enabled ? () => downloadImages() : null, + icon: const Icon(Icons.image), + tooltip: AppLocalizations.of(context)!.downloadMissingImages, + ); + } +} \ No newline at end of file diff --git a/lib/components/DownloadsScreen/downloaded_albums_list.dart b/lib/components/DownloadsScreen/downloaded_albums_list.dart new file mode 100644 index 0000000..ae5e68b --- /dev/null +++ b/lib/components/DownloadsScreen/downloaded_albums_list.dart @@ -0,0 +1,147 @@ +import 'package:finamp/services/jellyfin_api_helper.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; + +import '../../models/finamp_models.dart'; +import '../../services/downloads_helper.dart'; +import '../../models/jellyfin_models.dart'; +import '../album_image.dart'; +import '../confirmation_prompt_dialog.dart'; +import 'item_media_source_info.dart'; +import 'album_file_size.dart'; + +class DownloadedAlbumsList extends StatefulWidget { + const DownloadedAlbumsList({Key? key}) : super(key: key); + + @override + State createState() => _DownloadedAlbumsListState(); +} + +class _DownloadedAlbumsListState extends State { + final DownloadsHelper downloadsHelper = GetIt.instance(); + + final JellyfinApiHelper jellyfinApiHelper = JellyfinApiHelper(); + + Future deleteAlbum( + BuildContext context, DownloadedParent downloadedParent) async { + List itemIds = []; + for (BaseItemDto item in downloadedParent.downloadedChildren.values) { + itemIds.add(item.id); + } + await downloadsHelper.deleteParentAndChildDownloads( + jellyfinItemIds: itemIds, deletedFor: downloadedParent.item.id); + } + + @override + Widget build(BuildContext context) { + final Iterable downloadedParents = + downloadsHelper.downloadedParents; + + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + DownloadedParent album = downloadedParents.elementAt(index); + return ExpansionTile( + key: PageStorageKey(album.item.id), + leading: AlbumImage(item: album.item), + title: Text(album.item.name ?? "Unknown Name"), + trailing: IconButton( + icon: const Icon(Icons.delete), + onPressed: () => showDialog( + context: context, + builder: (context) => ConfirmationPromptDialog( + promptText: AppLocalizations.of(context)! + .deleteDownloadsPrompt( + album.item.name ?? "", + album.item.type == "Playlist" + ? "playlist" + : "album"), + confirmButtonText: AppLocalizations.of(context)! + .deleteDownloadsConfirmButtonText, + abortButtonText: AppLocalizations.of(context)! + .deleteDownloadsAbortButtonText, + onConfirmed: () async { + await deleteAlbum(context, album); + setState(() {}); + }, + onAborted: () {}, + ), + ), + ), + subtitle: AlbumFileSize( + downloadedParent: album, + ), + children: [ + DownloadedSongsInAlbumList( + children: album.downloadedChildren.values, + parent: album, + ) + ], + ); + }, + childCount: downloadedParents.length, + ), + ); + } +} + +class DownloadedSongsInAlbumList extends StatefulWidget { + const DownloadedSongsInAlbumList( + {Key? key, required this.children, required this.parent}) + : super(key: key); + + final Iterable children; + final DownloadedParent parent; + + @override + State createState() => + _DownloadedSongsInAlbumListState(); +} + +class _DownloadedSongsInAlbumListState + extends State { + final DownloadsHelper downloadsHelper = GetIt.instance(); + + @override + Widget build(BuildContext context) { + return Column(children: [ + //TODO use a list builder here + for (final song in widget.children) + ListTile( + title: Text(song.name ?? "Unknown Name"), + leading: AlbumImage(item: song), + trailing: IconButton( + icon: const Icon(Icons.delete), + onPressed: () => showDialog( + context: context, + builder: (context) => ConfirmationPromptDialog( + promptText: AppLocalizations.of(context)! + .deleteDownloadsPrompt( + song.name ?? "", + "track"), + confirmButtonText: AppLocalizations.of(context)! + .deleteDownloadsConfirmButtonText, + abortButtonText: AppLocalizations.of(context)! + .deleteDownloadsAbortButtonText, + onConfirmed: () async { + await deleteSong(context, song); + setState(() {}); + }, + onAborted: () {}, + ), + ), + ), + subtitle: ItemMediaSourceInfo( + songId: song.id, + ), + ) + ]); + } + + Future deleteSong(BuildContext context, BaseItemDto itemDto) async { + widget.parent.downloadedChildren + .removeWhere((key, value) => value == itemDto); + await downloadsHelper.deleteSong(jellyfinItemId: itemDto.id); + } +} diff --git a/lib/components/DownloadsScreen/downloads_file_size.dart b/lib/components/DownloadsScreen/downloads_file_size.dart new file mode 100644 index 0000000..8454c77 --- /dev/null +++ b/lib/components/DownloadsScreen/downloads_file_size.dart @@ -0,0 +1,56 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:file_sizes/file_sizes.dart'; + +import '../../services/downloads_helper.dart'; +import '../error_snackbar.dart'; + +class DownloadsFileSize extends StatefulWidget { + const DownloadsFileSize({Key? key, required this.directory}) + : super(key: key); + + final Directory directory; + + @override + State createState() => _DownloadsFileSizeState(); +} + +class _DownloadsFileSizeState extends State { + late Future _downloadsFileSizeFuture; + final DownloadsHelper _downloadsHelper = GetIt.instance(); + + @override + void initState() { + super.initState(); + _downloadsFileSizeFuture = _downloadsHelper.getDirSize(widget.directory); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _downloadsFileSizeFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + return Text( + FileSize.getSize(snapshot.data), + style: const TextStyle(color: Colors.grey), + ); + } + if (snapshot.hasError) { + errorSnackbar(snapshot.error, context); + return const Text( + "??? MB", + style: TextStyle(color: Colors.red), + ); + } else { + return const Text( + "...", + style: TextStyle(color: Colors.grey), + ); + } + }, + ); + } +} diff --git a/lib/components/DownloadsScreen/downloads_overview.dart b/lib/components/DownloadsScreen/downloads_overview.dart new file mode 100644 index 0000000..a9d74f7 --- /dev/null +++ b/lib/components/DownloadsScreen/downloads_overview.dart @@ -0,0 +1,185 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:finamp/services/downloads_helper.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_downloader/flutter_downloader.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; + +import '../../services/download_update_stream.dart'; +import '../error_snackbar.dart'; + +const double downloadsOverviewCardLoadingHeight = 120; + +class DownloadsOverview extends StatefulWidget { + const DownloadsOverview({Key? key}) : super(key: key); + + @override + State createState() => _DownloadsOverviewState(); +} + +class _DownloadsOverviewState extends State { + late Future?> _downloadsOverviewFuture; + final _downloadUpdateStream = GetIt.instance(); + final _downloadsHelper = GetIt.instance(); + + Map? _downloadTaskStatuses; + + final Map _downloadCount = { + DownloadTaskStatus.undefined: 0, + DownloadTaskStatus.enqueued: 0, + DownloadTaskStatus.running: 0, + DownloadTaskStatus.complete: 0, + DownloadTaskStatus.failed: 0, + DownloadTaskStatus.canceled: 0, + DownloadTaskStatus.paused: 0, + }; + + bool _initialCountDone = false; + + @override + void initState() { + super.initState(); + _downloadsOverviewFuture = FlutterDownloader.loadTasks(); + + // Like in DownloadedIndicator, we use our own listener instead of a + // StreamBuilder to ensure that we capture all events. + _downloadUpdateStream.stream.listen((event) { + if (_downloadTaskStatuses != null && + _downloadTaskStatuses!.containsKey(event.id) && + _downloadTaskStatuses![event.id] != event.status) { + setState(() { + _downloadCount[_downloadTaskStatuses![event.id]!] = + _downloadCount[_downloadTaskStatuses![event.id]]! - 1; + _downloadCount[event.status] = _downloadCount[event.status]! + 1; + _downloadTaskStatuses![event.id] = event.status; + }); + } + }); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder?>( + future: _downloadsOverviewFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + _downloadTaskStatuses ??= Map.fromEntries( + snapshot.data!.map((e) => MapEntry(e.taskId, e.status))); + + if (!_initialCountDone) { + // Switch cases don't work for some reason + for (var element in snapshot.data!) { + if (element.status == DownloadTaskStatus.undefined) { + _downloadCount[DownloadTaskStatus.undefined] = + _downloadCount[DownloadTaskStatus.undefined]! + 1; + } else if (element.status == DownloadTaskStatus.enqueued) { + _downloadCount[DownloadTaskStatus.enqueued] = + _downloadCount[DownloadTaskStatus.enqueued]! + 1; + } else if (element.status == DownloadTaskStatus.running) { + _downloadCount[DownloadTaskStatus.running] = + _downloadCount[DownloadTaskStatus.running]! + 1; + } else if (element.status == DownloadTaskStatus.complete) { + _downloadCount[DownloadTaskStatus.complete] = + _downloadCount[DownloadTaskStatus.complete]! + 1; + } else if (element.status == DownloadTaskStatus.failed) { + _downloadCount[DownloadTaskStatus.failed] = + _downloadCount[DownloadTaskStatus.failed]! + 1; + } else if (element.status == DownloadTaskStatus.canceled) { + _downloadCount[DownloadTaskStatus.canceled] = + _downloadCount[DownloadTaskStatus.canceled]! + 1; + } else if (element.status == DownloadTaskStatus.paused) { + _downloadCount[DownloadTaskStatus.paused] = + _downloadCount[DownloadTaskStatus.paused]! + 1; + } + } + _initialCountDone = true; + } + + // We have to awkwardly get two strings like this because Flutter's + // internationalisation stuff doesn't support multiple plurals. + // https://github.com/flutter/flutter/issues/86906 + final downloadedItemsString = AppLocalizations.of(context)! + .downloadedItemsCount(_downloadsHelper.downloadedItems.length); + final downloadedImagesString = AppLocalizations.of(context)! + .downloadedImagesCount(_downloadsHelper.downloadedImages.length); + + return Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AutoSizeText( + AppLocalizations.of(context)! + .downloadCount(snapshot.data!.length), + style: const TextStyle(fontSize: 28), + maxLines: 1, + ), + Text( + AppLocalizations.of(context)! + .downloadedItemsImagesCount( + downloadedItemsString, + downloadedImagesString), + style: const TextStyle(color: Colors.grey), + ) + ], + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + AppLocalizations.of(context)!.dlComplete( + _downloadCount[DownloadTaskStatus.complete]!), + style: const TextStyle(color: Colors.green), + ), + Text( + AppLocalizations.of(context)!.dlFailed( + _downloadCount[DownloadTaskStatus.failed]!), + style: const TextStyle(color: Colors.red), + ), + Text( + AppLocalizations.of(context)!.dlEnqueued( + _downloadCount[DownloadTaskStatus.enqueued]!), + style: const TextStyle(color: Colors.grey), + ), + Text( + AppLocalizations.of(context)!.dlRunning( + _downloadCount[DownloadTaskStatus.running]!), + style: const TextStyle(color: Colors.grey), + ), + ], + ), + ) + ], + ), + ), + ), + ); + } else if (snapshot.hasError) { + errorSnackbar(snapshot.error, context); + return const SizedBox( + height: downloadsOverviewCardLoadingHeight, + child: Card( + child: Icon(Icons.error), + ), + ); + } else { + return const SizedBox( + height: downloadsOverviewCardLoadingHeight, + child: Card( + child: Center(child: CircularProgressIndicator.adaptive()), + ), + ); + } + }, + ); + } +} diff --git a/lib/components/DownloadsScreen/item_media_source_info.dart b/lib/components/DownloadsScreen/item_media_source_info.dart new file mode 100644 index 0000000..676b8a1 --- /dev/null +++ b/lib/components/DownloadsScreen/item_media_source_info.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:file_sizes/file_sizes.dart'; +import 'package:get_it/get_it.dart'; + +import '../../services/downloads_helper.dart'; +import '../../models/jellyfin_models.dart'; + +class ItemMediaSourceInfo extends StatelessWidget { + const ItemMediaSourceInfo({Key? key, required this.songId}) : super(key: key); + + final String songId; + + @override + Widget build(BuildContext context) { + DownloadsHelper downloadsHelper = GetIt.instance(); + MediaSourceInfo? mediaSourceInfo = + downloadsHelper.getDownloadedSong(songId)?.mediaSourceInfo; + + if (mediaSourceInfo == null) { + return const Text("??? MB Unknown"); + } else { + return Text( + "${FileSize.getSize(mediaSourceInfo.size)} ${mediaSourceInfo.container?.toUpperCase()}"); + } + } +} diff --git a/lib/components/DownloadsScreen/sync_downloaded_playlists.dart b/lib/components/DownloadsScreen/sync_downloaded_playlists.dart new file mode 100644 index 0000000..d8881a7 --- /dev/null +++ b/lib/components/DownloadsScreen/sync_downloaded_playlists.dart @@ -0,0 +1,45 @@ +import 'package:finamp/models/finamp_models.dart'; +import 'package:finamp/models/jellyfin_models.dart'; +import 'package:finamp/services/downloads_helper.dart'; +import 'package:finamp/services/jellyfin_api_helper.dart'; +import 'package:finamp/services/sync_helper.dart'; +import 'package:flutter_downloader/flutter_downloader.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logging/logging.dart'; + +class SyncDownloadedAlbumsOrPlaylistsButton extends StatelessWidget { + SyncDownloadedAlbumsOrPlaylistsButton({super.key}); + + final _syncLogger = Logger("SyncDownloadedPlaylistsButton"); + final DownloadsHelper downloadsHelper = GetIt.instance(); + final _jellyfinApiData = GetIt.instance(); + + void syncPlaylists(BuildContext context) async { + _syncLogger.info("Syncing downloaded playlists"); + + var syncHelper = DownloadsSyncHelper(_syncLogger); + List parents = downloadsHelper.downloadedParents.toList(); + + for (DownloadedParent parent in parents) { + List? items = await _jellyfinApiData.getItems( + isGenres: false, parentItem: parent.item); + + if (items == null) { + _syncLogger.warning("Could not find any items for album or playlist id ${parent.item.id}"); + continue; + } + syncHelper.sync(context, parent.item, items); + } + } + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: () => syncPlaylists(context), + icon: const Icon(Icons.sync), + tooltip: AppLocalizations.of(context)!.syncDownloadedPlaylists, + ); + } +} diff --git a/lib/components/InteractionSettingsScreen/FastScrollSelector.dart b/lib/components/InteractionSettingsScreen/FastScrollSelector.dart new file mode 100644 index 0000000..3979206 --- /dev/null +++ b/lib/components/InteractionSettingsScreen/FastScrollSelector.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hive/hive.dart'; + +import '../../models/finamp_models.dart'; +import '../../services/finamp_settings_helper.dart'; + +class FastScrollSelector extends StatelessWidget { + const FastScrollSelector({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (_, box, __) { + return SwitchListTile.adaptive( + title: Text(AppLocalizations.of(context)!.showFastScroller), + value: FinampSettingsHelper.finampSettings.showFastScroller, + onChanged: (value) => FinampSettingsHelper.setShowFastScroller(value), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/components/InteractionSettingsScreen/disable_gestures.dart b/lib/components/InteractionSettingsScreen/disable_gestures.dart new file mode 100644 index 0000000..10b0190 --- /dev/null +++ b/lib/components/InteractionSettingsScreen/disable_gestures.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hive/hive.dart'; + +import '../../models/finamp_models.dart'; +import '../../services/finamp_settings_helper.dart'; + +class DisableGestureSelector extends StatelessWidget { + const DisableGestureSelector({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (_, box, __) { + return SwitchListTile.adaptive( + title: Text(AppLocalizations.of(context)!.disableGesture), + subtitle: Text(AppLocalizations.of(context)!.disableGestureSubtitle), + value: FinampSettingsHelper.finampSettings.disableGesture, + onChanged: (value) => FinampSettingsHelper.setDisableGesture(value), + ); + }, + ); + } +} diff --git a/lib/components/InteractionSettingsScreen/swipe_insert_queue_next_selector.dart b/lib/components/InteractionSettingsScreen/swipe_insert_queue_next_selector.dart new file mode 100644 index 0000000..c9047a4 --- /dev/null +++ b/lib/components/InteractionSettingsScreen/swipe_insert_queue_next_selector.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hive/hive.dart'; + +import '../../services/finamp_settings_helper.dart'; +import '../../models/finamp_models.dart'; + +class SwipeInsertQueueNextSelector extends StatelessWidget { + const SwipeInsertQueueNextSelector({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (_, box, __) { + return SwitchListTile.adaptive( + title: Text(AppLocalizations.of(context)!.swipeInsertQueueNext), + subtitle: + Text(AppLocalizations.of(context)!.swipeInsertQueueNextSubtitle), + value: FinampSettingsHelper.finampSettings.swipeInsertQueueNext, + onChanged: (value) => + FinampSettingsHelper.setSwipeInsertQueueNext(value), + ); + }, + ); + } +} diff --git a/lib/components/LanguageSelectionScreen/language_list.dart b/lib/components/LanguageSelectionScreen/language_list.dart new file mode 100644 index 0000000..1e6c308 --- /dev/null +++ b/lib/components/LanguageSelectionScreen/language_list.dart @@ -0,0 +1,93 @@ +import 'dart:collection'; + +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:locale_names/locale_names.dart'; + +import '../../services/locale_helper.dart'; + +class LanguageList extends StatefulWidget { + const LanguageList({super.key}); + + @override + State createState() => _LanguageListState(); +} + +class _LanguageListState extends State { + // yeah I'm a computer science student how could you tell + // (sorts locales without having to copy them into a list first) + final locales = SplayTreeMap.fromIterable( + AppLocalizations.supportedLocales, + key: (element) => (element as Locale).defaultDisplayLanguage, + value: (element) => element, + ); + + @override + Widget build(BuildContext context) { + return Scrollbar( + // We have a ValueListenableBuilder here to rebuild all the ListTiles when + // the language changes + child: ValueListenableBuilder( + valueListenable: LocaleHelper.localeListener, + builder: (_, __, ___) { + return CustomScrollView( + slivers: [ + // For some reason, setting the null (system) LanguageListTile to + // const stops it from switching when going to/from the same + // language as the system language (e.g., system to English on a + // device set to English) + // ignore: prefer_const_constructors + SliverList( + // ignore: prefer_const_constructors + delegate: SliverChildListDelegate.fixed([ + // ignore: prefer_const_constructors + LanguageListTile(), + const Divider(), + ]), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final locale = locales.values.elementAt(index); + + return LanguageListTile(locale: locale); + }, + childCount: locales.length, + ), + ) + ], + ); + }, + ), + ); + } +} + +class LanguageListTile extends StatelessWidget { + const LanguageListTile({ + super.key, + this.locale, + }); + + final Locale? locale; + + @override + Widget build(BuildContext context) { + return RadioListTile( + title: Text(locale?.nativeDisplayLanguage ?? + AppLocalizations.of(context)!.system), + subtitle: locale == null + ? null + : Text((LocaleHelper.locale == null + ? locale!.defaultDisplayLanguage + : locale!.displayLanguageIn(LocaleHelper.locale!)) ?? + "???"), + value: locale, + groupValue: LocaleHelper.locale, + onChanged: (_) { + LocaleHelper.setLocale(locale); + }, + ); + } +} diff --git a/lib/components/LayoutSettingsScreen/content_grid_view_cross_axis_count_list_tile.dart b/lib/components/LayoutSettingsScreen/content_grid_view_cross_axis_count_list_tile.dart new file mode 100644 index 0000000..7025a99 --- /dev/null +++ b/lib/components/LayoutSettingsScreen/content_grid_view_cross_axis_count_list_tile.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../../services/finamp_settings_helper.dart'; + +enum ContentGridViewCrossAxisCountType { + portrait, + landscape; + + /// Human-readable version of the [ContentGridViewCrossAxisCountType]. For + /// example, toString() on [ContentGridViewCrossAxisCountType.portrait], + /// toString() would return "ContentGridViewCrossAxisCountType.portrait". With + /// this function, the same input would return "Portrait". + @override + @Deprecated("Use toLocalisedString when possible") + String toString() => _humanReadableName(this); + + String toLocalisedString(BuildContext context) => + _humanReadableLocalisedName(this, context); + + String _humanReadableName( + ContentGridViewCrossAxisCountType contentGridViewCrossAxisCountType) { + switch (contentGridViewCrossAxisCountType) { + case ContentGridViewCrossAxisCountType.portrait: + return "Portrait"; + case ContentGridViewCrossAxisCountType.landscape: + return "Landscape"; + } + } + + String _humanReadableLocalisedName( + ContentGridViewCrossAxisCountType contentGridViewCrossAxisCountType, + BuildContext context) { + switch (contentGridViewCrossAxisCountType) { + case ContentGridViewCrossAxisCountType.portrait: + return AppLocalizations.of(context)!.portrait; + case ContentGridViewCrossAxisCountType.landscape: + return AppLocalizations.of(context)!.landscape; + } + } +} + +class ContentGridViewCrossAxisCountListTile extends StatefulWidget { + const ContentGridViewCrossAxisCountListTile({ + Key? key, + required this.type, + }) : super(key: key); + + final ContentGridViewCrossAxisCountType type; + + @override + State createState() => + _ContentGridViewCrossAxisCountListTileState(); +} + +class _ContentGridViewCrossAxisCountListTileState + extends State { + final _controller = TextEditingController(); + + @override + void initState() { + super.initState(); + switch (widget.type) { + case ContentGridViewCrossAxisCountType.portrait: + _controller.text = FinampSettingsHelper + .finampSettings.contentGridViewCrossAxisCountPortrait + .toString(); + break; + case ContentGridViewCrossAxisCountType.landscape: + _controller.text = FinampSettingsHelper + .finampSettings.contentGridViewCrossAxisCountLandscape + .toString(); + break; + } + } + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(AppLocalizations.of(context)! + .gridCrossAxisCount(widget.type.toLocalisedString(context))), + subtitle: Text( + AppLocalizations.of(context)!.gridCrossAxisCountSubtitle( + widget.type.toLocalisedString(context).toLowerCase()), + ), + trailing: SizedBox( + width: 50 * MediaQuery.of(context).textScaleFactor, + child: TextField( + controller: _controller, + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + onChanged: (value) { + final valueInt = int.tryParse(value); + + if (valueInt != null && valueInt > 0) { + switch (widget.type) { + case ContentGridViewCrossAxisCountType.portrait: + FinampSettingsHelper.setContentGridViewCrossAxisCountPortrait( + valueInt); + break; + case ContentGridViewCrossAxisCountType.landscape: + FinampSettingsHelper + .setContentGridViewCrossAxisCountLandscape(valueInt); + break; + } + } + }, + ), + ), + ); + } +} diff --git a/lib/components/LayoutSettingsScreen/content_view_type_dropdown_list_tile.dart b/lib/components/LayoutSettingsScreen/content_view_type_dropdown_list_tile.dart new file mode 100644 index 0000000..a7d9c93 --- /dev/null +++ b/lib/components/LayoutSettingsScreen/content_view_type_dropdown_list_tile.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hive/hive.dart'; + +import '../../models/finamp_models.dart'; +import '../../services/finamp_settings_helper.dart'; + +class ContentViewTypeDropdownListTile extends StatelessWidget { + const ContentViewTypeDropdownListTile({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (_, box, __) { + return ListTile( + title: Text(AppLocalizations.of(context)!.viewType), + subtitle: Text(AppLocalizations.of(context)!.viewTypeSubtitle), + trailing: DropdownButton( + value: box.get("FinampSettings")?.contentViewType, + items: ContentViewType.values + .map((e) => DropdownMenuItem( + value: e, + child: Text(e.toLocalisedString(context)), + )) + .toList(), + onChanged: (value) { + if (value != null) { + FinampSettingsHelper.setContentViewType(value); + } + }, + ), + ); + }, + ); + } +} diff --git a/lib/components/LayoutSettingsScreen/hide_song_artists_if_same_as_album_artists_selector.dart b/lib/components/LayoutSettingsScreen/hide_song_artists_if_same_as_album_artists_selector.dart new file mode 100644 index 0000000..d7db182 --- /dev/null +++ b/lib/components/LayoutSettingsScreen/hide_song_artists_if_same_as_album_artists_selector.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hive/hive.dart'; + +import '../../models/finamp_models.dart'; +import '../../services/finamp_settings_helper.dart'; + +class HideSongArtistsIfSameAsAlbumArtistsSelector extends StatelessWidget { + const HideSongArtistsIfSameAsAlbumArtistsSelector({Key? key}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (_, box, __) { + return SwitchListTile.adaptive( + title: Text(AppLocalizations.of(context)! + .hideSongArtistsIfSameAsAlbumArtists), + subtitle: Text(AppLocalizations.of(context)! + .hideSongArtistsIfSameAsAlbumArtistsSubtitle), + value: FinampSettingsHelper + .finampSettings.hideSongArtistsIfSameAsAlbumArtists, + onChanged: (value) => + FinampSettingsHelper.setHideSongArtistsIfSameAsAlbumArtists( + value), + ); + }, + ); + } +} diff --git a/lib/components/LayoutSettingsScreen/show_cover_as_player_background_selector.dart b/lib/components/LayoutSettingsScreen/show_cover_as_player_background_selector.dart new file mode 100644 index 0000000..df031f9 --- /dev/null +++ b/lib/components/LayoutSettingsScreen/show_cover_as_player_background_selector.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hive/hive.dart'; + +import '../../models/finamp_models.dart'; +import '../../services/finamp_settings_helper.dart'; + +class ShowCoverAsPlayerBackgroundSelector extends StatelessWidget { + const ShowCoverAsPlayerBackgroundSelector({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (_, box, __) { + return SwitchListTile.adaptive( + title: + Text(AppLocalizations.of(context)!.showCoverAsPlayerBackground), + subtitle: Text(AppLocalizations.of(context)! + .showCoverAsPlayerBackgroundSubtitle), + value: + FinampSettingsHelper.finampSettings.showCoverAsPlayerBackground, + onChanged: (value) => + FinampSettingsHelper.setShowCoverAsPlayerBackground(value), + ); + }, + ); + } +} diff --git a/lib/components/LayoutSettingsScreen/show_text_on_grid_view_selector.dart b/lib/components/LayoutSettingsScreen/show_text_on_grid_view_selector.dart new file mode 100644 index 0000000..669daa7 --- /dev/null +++ b/lib/components/LayoutSettingsScreen/show_text_on_grid_view_selector.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hive/hive.dart'; + +import '../../models/finamp_models.dart'; +import '../../services/finamp_settings_helper.dart'; + +class ShowTextOnGridViewSelector extends StatelessWidget { + const ShowTextOnGridViewSelector({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (_, box, __) { + return SwitchListTile.adaptive( + title: Text(AppLocalizations.of(context)!.showTextOnGridView), + subtitle: + Text(AppLocalizations.of(context)!.showTextOnGridViewSubtitle), + value: FinampSettingsHelper.finampSettings.showTextOnGridView, + onChanged: (value) => + FinampSettingsHelper.setShowTextOnGridView(value), + ); + }, + ); + } +} diff --git a/lib/components/LayoutSettingsScreen/theme_selector.dart b/lib/components/LayoutSettingsScreen/theme_selector.dart new file mode 100644 index 0000000..8994a67 --- /dev/null +++ b/lib/components/LayoutSettingsScreen/theme_selector.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hive/hive.dart'; + +import '../../services/theme_mode_helper.dart'; + +extension LocalisedName on ThemeMode { + String toLocalisedString(BuildContext context) => + _humanReadableLocalisedName(this, context); + + String _humanReadableLocalisedName( + ThemeMode themeMode, BuildContext context) { + switch (themeMode) { + case ThemeMode.system: + return AppLocalizations.of(context)!.system; + case ThemeMode.light: + return AppLocalizations.of(context)!.light; + case ThemeMode.dark: + return AppLocalizations.of(context)!.dark; + } + } +} + +class ThemeSelector extends StatelessWidget { + const ThemeSelector({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: ThemeModeHelper.themeModeListener, + builder: (_, box, __) { + return ListTile( + title: const Text("Theme"), + trailing: DropdownButton( + value: box.get("ThemeMode"), + items: ThemeMode.values + .map((e) => DropdownMenuItem( + value: e, + child: Text(e.toLocalisedString(context)), + )) + .toList(), + onChanged: (value) { + if (value != null) { + ThemeModeHelper.setThemeMode(value); + } + }, + ), + ); + }, + ); + } +} diff --git a/lib/components/LogsScreen/copy_logs_button.dart b/lib/components/LogsScreen/copy_logs_button.dart new file mode 100644 index 0000000..040554d --- /dev/null +++ b/lib/components/LogsScreen/copy_logs_button.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; + +import '../../services/finamp_logs_helper.dart'; + +class CopyLogsButton extends StatefulWidget { + const CopyLogsButton({Key? key}) : super(key: key); + + @override + State createState() => _CopyLogsButtonState(); +} + +class _CopyLogsButtonState extends State { + @override + Widget build(BuildContext context) { + return IconButton( + icon: const Icon(Icons.copy), + onPressed: () async { + final finampLogsHelper = GetIt.instance(); + + await finampLogsHelper.copyLogs(); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.logsCopied), + )); + }, + ); + } +} diff --git a/lib/components/LogsScreen/log_tile.dart b/lib/components/LogsScreen/log_tile.dart new file mode 100644 index 0000000..6711435 --- /dev/null +++ b/lib/components/LogsScreen/log_tile.dart @@ -0,0 +1,181 @@ +import 'package:clipboard/clipboard.dart'; +import 'package:finamp/services/censored_log.dart'; +import 'package:finamp/services/contains_login.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:logging/logging.dart'; + +import '../error_snackbar.dart'; + +class LogTile extends StatefulWidget { + const LogTile({Key? key, required this.logRecord}) : super(key: key); + + final LogRecord logRecord; + + @override + State createState() => _LogTileState(); +} + +class _LogTileState extends State { + final _controller = ExpansionTileController(); + + /// Whether the user has confirmed. This is used to stop onExpansionChanged + /// from infinitely asking the user to confirm. + bool hasConfirmed = false; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onLongPress: () async { + try { + await FlutterClipboard.copy(widget.logRecord.censoredMessage); + } catch (e) { + errorSnackbar(e, context); + } + }, + child: Card( + color: _logColor(widget.logRecord.level, context), + child: ExpansionTile( + controller: _controller, + key: PageStorageKey(widget.logRecord.time), + leading: _LogIcon(level: widget.logRecord.level), + title: RichText( + maxLines: 2, + overflow: TextOverflow.ellipsis, + text: TextSpan( + text: + "[${widget.logRecord.loggerName}]\n${widget.logRecord.time}", + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + subtitle: RichText( + maxLines: 2, + overflow: TextOverflow.ellipsis, + text: TextSpan( + text: widget.logRecord.loginCensoredMessage, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + expandedAlignment: Alignment.centerLeft, + expandedCrossAxisAlignment: CrossAxisAlignment.start, + textColor: _logTextColor(widget.logRecord.level, context), + collapsedTextColor: _logTextColor(widget.logRecord.level, context), + iconColor: _logTextColor(widget.logRecord.level, context), + collapsedIconColor: _logTextColor(widget.logRecord.level, context), + // Remove the border when expanded + shape: const Border(), + childrenPadding: const EdgeInsets.all(8.0), + children: [ + Text( + AppLocalizations.of(context)!.message, + style: Theme.of(context).textTheme.titleLarge, + ), + _LogMessageContent(widget.logRecord.message), + const SizedBox(height: 16.0), + if (widget.logRecord.stackTrace != null) + Text( + AppLocalizations.of(context)!.stackTrace, + style: Theme.of(context).textTheme.titleLarge, + ), + if (widget.logRecord.stackTrace != null) + _LogMessageContent(widget.logRecord.stackTrace.toString()), + ], + onExpansionChanged: (value) async { + if (value && !hasConfirmed && widget.logRecord.containsLogin) { + _controller.collapse(); + + final confirmed = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + return AlertDialog( + title: Text(AppLocalizations.of(context)!.confirm), + content: Text( + AppLocalizations.of(context)!.showUncensoredLogMessage), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(MaterialLocalizations.of(context) + .cancelButtonLabel), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(AppLocalizations.of(context)!.confirm), + ), + ], + ); + }, + ); + + hasConfirmed = confirmed!; + + if (confirmed) { + _controller.expand(); + } + } else if (hasConfirmed) { + hasConfirmed = false; + } + }, + ), + ), + ); + } + + Color _logColor(Level level, BuildContext context) { + if (level == Level.WARNING) { + return Theme.of(context).colorScheme.tertiaryContainer; + } else if (level == Level.SEVERE) { + return Theme.of(context).colorScheme.errorContainer; + } else { + return Theme.of(context).colorScheme.primaryContainer; + } + } + + Color _logTextColor(Level level, BuildContext context) { + if (level == Level.WARNING) { + return Theme.of(context).colorScheme.onTertiaryContainer; + } else if (level == Level.SEVERE) { + return Theme.of(context).colorScheme.onErrorContainer; + } else { + return Theme.of(context).colorScheme.onPrimaryContainer; + } + } +} + +class _LogIcon extends StatelessWidget { + const _LogIcon({Key? key, required this.level}) : super(key: key); + + final Level level; + + @override + Widget build(BuildContext context) { + if (level == Level.INFO) { + return const Icon(Icons.info); + } else if (level == Level.WARNING) { + return const Icon(Icons.warning); + } else if (level == Level.SEVERE) { + return const Icon(Icons.error); + } else { + return const Icon(Icons.info); + } + } +} + +class _LogMessageContent extends StatelessWidget { + const _LogMessageContent(this.content, {Key? key}) : super(key: key); + + final String content; + + @override + Widget build(BuildContext context) { + return Text( + content, + style: const TextStyle( + fontSize: 12.0, + fontFamily: "monospace", + ), + ); + } +} \ No newline at end of file diff --git a/lib/components/LogsScreen/logs_view.dart b/lib/components/LogsScreen/logs_view.dart new file mode 100644 index 0000000..08cbe35 --- /dev/null +++ b/lib/components/LogsScreen/logs_view.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; + +import 'log_tile.dart'; +import '../../services/finamp_logs_helper.dart'; + +class LogsView extends StatelessWidget { + const LogsView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + FinampLogsHelper finampLogsHelper = GetIt.instance(); + + return Scrollbar( + child: ListView.builder( + itemCount: finampLogsHelper.logs.length, + reverse: true, + itemBuilder: (context, index) { + return LogTile( + logRecord: finampLogsHelper.logs.reversed.elementAt(index)); + }, + ), + ); + } +} diff --git a/lib/components/LogsScreen/share_logs_button.dart b/lib/components/LogsScreen/share_logs_button.dart new file mode 100644 index 0000000..10fa454 --- /dev/null +++ b/lib/components/LogsScreen/share_logs_button.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; + +import '../../services/finamp_logs_helper.dart'; + +class ShareLogsButton extends StatelessWidget { + const ShareLogsButton({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return IconButton( + icon: Icon(Icons.adaptive.share), + tooltip: AppLocalizations.of(context)!.shareLogs, + onPressed: () async { + final finampLogsHelper = GetIt.instance(); + + await finampLogsHelper.shareLogs(); + }, + ); + } +} diff --git a/lib/components/MusicScreen/album_item.dart b/lib/components/MusicScreen/album_item.dart new file mode 100644 index 0000000..fa20acc --- /dev/null +++ b/lib/components/MusicScreen/album_item.dart @@ -0,0 +1,311 @@ +import 'package:finamp/components/MusicScreen/album_item_list_tile.dart'; +import 'package:finamp/services/downloads_helper.dart'; +import 'package:finamp/services/finamp_settings_helper.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; + +import '../../models/jellyfin_models.dart'; +import '../../services/audio_service_helper.dart'; +import '../../services/jellyfin_api_helper.dart'; +import '../../screens/artist_screen.dart'; +import '../../screens/album_screen.dart'; +import '../error_snackbar.dart'; +import 'album_item_card.dart'; + +enum _AlbumListTileMenuItems { + addToQueue, + playNext, + addFavourite, + removeFavourite, + addToMixList, + removeFromMixList, +} + +/// This widget is kind of a shell around AlbumItemCard and AlbumItemListTile. +/// Depending on the values given, a list tile or a card will be returned. This +/// widget exists to handle the dropdown stuff and other stuff shared between +/// the two widgets. +class AlbumItem extends StatefulWidget { + const AlbumItem({ + Key? key, + required this.album, + this.parentType, + this.onTap, + this.isGrid = false, + this.gridAddSettingsListener = false, + }) : super(key: key); + + /// The album (or item, I just used to call items albums before Finamp + /// supported other types) to show in the widget. + final BaseItemDto album; + + /// The parent type of the item. Used to change onTap functionality for stuff + /// like artists. + final String? parentType; + + /// A custom onTap can be provided to override the default value, which is to + /// open the item's album/artist screen. + final void Function()? onTap; + + /// If specified, use cards instead of list tiles. Use this if you want to use + /// this widget in a grid view. + final bool isGrid; + + /// If true, the grid item will use a ValueListenableBuilder to check whether + /// or not to show the text. You'll want to set this to false if the + /// [AlbumItem] would be rebuilt by FinampSettings anyway. + final bool gridAddSettingsListener; + + @override + State createState() => _AlbumItemState(); +} + +class _AlbumItemState extends State { + final _audioServiceHelper = GetIt.instance(); + + late BaseItemDto mutableAlbum; + + late Function() onTap; + + @override + void initState() { + super.initState(); + mutableAlbum = widget.album; + + // this is jank lol + onTap = widget.onTap ?? + () { + if (mutableAlbum.type == "MusicArtist" || + mutableAlbum.type == "MusicGenre") { + Navigator.of(context) + .pushNamed(ArtistScreen.routeName, arguments: mutableAlbum); + } else { + Navigator.of(context) + .pushNamed(AlbumScreen.routeName, arguments: mutableAlbum); + } + }; + } + + @override + Widget build(BuildContext context) { + final screenSize = MediaQuery.of(context).size; + + return Padding( + padding: widget.isGrid + ? Theme.of(context).cardTheme.margin ?? const EdgeInsets.all(4.0) + : EdgeInsets.zero, + child: GestureDetector( + onLongPressStart: (details) async { + Feedback.forLongPress(context); + + final isOffline = FinampSettingsHelper.finampSettings.isOffline; + + final jellyfinApiHelper = GetIt.instance(); + + final selection = await showMenu<_AlbumListTileMenuItems>( + context: context, + position: RelativeRect.fromLTRB( + details.globalPosition.dx, + details.globalPosition.dy, + screenSize.width - details.globalPosition.dx, + screenSize.height - details.globalPosition.dy, + ), + items: [ + if (_audioServiceHelper.hasQueueItems()) ...[ + PopupMenuItem<_AlbumListTileMenuItems>( + value: _AlbumListTileMenuItems.addToQueue, + child: ListTile( + leading: const Icon(Icons.queue_music), + title: Text(AppLocalizations.of(context)!.addToQueue), + ), + ), + PopupMenuItem<_AlbumListTileMenuItems>( + value: _AlbumListTileMenuItems.playNext, + child: ListTile( + leading: const Icon(Icons.queue_music), + title: Text(AppLocalizations.of(context)!.playNext), + ), + ), + ], + mutableAlbum.userData!.isFavorite + ? PopupMenuItem<_AlbumListTileMenuItems>( + enabled: !isOffline, + value: _AlbumListTileMenuItems.removeFavourite, + child: ListTile( + enabled: !isOffline, + leading: const Icon(Icons.favorite_border), + title: + Text(AppLocalizations.of(context)!.removeFavourite), + ), + ) + : PopupMenuItem<_AlbumListTileMenuItems>( + enabled: !isOffline, + value: _AlbumListTileMenuItems.addFavourite, + child: ListTile( + enabled: !isOffline, + leading: const Icon(Icons.favorite), + title: Text(AppLocalizations.of(context)!.addFavourite), + ), + ), + jellyfinApiHelper.selectedMixAlbumIds.contains(mutableAlbum.id) + ? PopupMenuItem<_AlbumListTileMenuItems>( + enabled: !isOffline, + value: _AlbumListTileMenuItems.removeFromMixList, + child: ListTile( + enabled: !isOffline, + leading: const Icon(Icons.explore_off), + title: + Text(AppLocalizations.of(context)!.removeFromMix), + ), + ) + : PopupMenuItem<_AlbumListTileMenuItems>( + enabled: !isOffline, + value: _AlbumListTileMenuItems.addToMixList, + child: ListTile( + enabled: !isOffline, + leading: const Icon(Icons.explore), + title: Text(AppLocalizations.of(context)!.addToMix), + ), + ), + ], + ); + + if (!mounted) return; + + switch (selection) { + case _AlbumListTileMenuItems.addToQueue: + List? children; + + if (isOffline) { + final downloadsHelper = GetIt.instance(); + + // The downloadedParent won't be null here if we've already + // navigated to it in offline mode + final downloadedParent = + downloadsHelper.getDownloadedParent(widget.album.id)!; + + children = downloadedParent.downloadedChildren.values.toList(); + } else { + children = await jellyfinApiHelper.getItems( + parentItem: widget.album, + sortBy: "ParentIndexNumber,IndexNumber,SortName", + includeItemTypes: "Audio", + isGenres: false, + ); + } + + if (children != null) { + await _audioServiceHelper.addQueueItems(children); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.addedToQueue), + )); + } + + break; + + case _AlbumListTileMenuItems.playNext: + List? children; + if (isOffline) { + final downloadsHelper = GetIt.instance(); + + // The downloadedParent won't be null here if we've already + // navigated to it in offline mode + final downloadedParent = + downloadsHelper.getDownloadedParent(widget.album.id)!; + + children = downloadedParent.downloadedChildren.values.toList(); + } else { + children = await jellyfinApiHelper.getItems( + parentItem: widget.album, + sortBy: "ParentIndexNumber,IndexNumber,SortName", + includeItemTypes: "Audio", + isGenres: false, + ); + } + + if (children != null) { + await _audioServiceHelper.insertQueueItemsNext(children); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: + Text(AppLocalizations.of(context)!.insertedIntoQueue), + )); + } + + break; + + case _AlbumListTileMenuItems.addFavourite: + try { + final newUserData = + await jellyfinApiHelper.addFavourite(mutableAlbum.id); + + if (!mounted) return; + + setState(() { + mutableAlbum.userData = newUserData; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Favourite added."))); + } catch (e) { + errorSnackbar(e, context); + } + break; + case _AlbumListTileMenuItems.removeFavourite: + try { + final newUserData = + await jellyfinApiHelper.removeFavourite(mutableAlbum.id); + + if (!mounted) return; + + setState(() { + mutableAlbum.userData = newUserData; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Favourite removed."))); + } catch (e) { + errorSnackbar(e, context); + } + break; + case _AlbumListTileMenuItems.addToMixList: + try { + jellyfinApiHelper.addAlbumToMixBuilderList(mutableAlbum); + setState(() {}); + } catch (e) { + errorSnackbar(e, context); + } + break; + case _AlbumListTileMenuItems.removeFromMixList: + try { + jellyfinApiHelper.removeAlbumFromBuilderList(mutableAlbum); + setState(() {}); + } catch (e) { + errorSnackbar(e, context); + } + break; + case null: + break; + } + }, + child: widget.isGrid + ? AlbumItemCard( + item: mutableAlbum, + onTap: onTap, + parentType: widget.parentType, + addSettingsListener: widget.gridAddSettingsListener, + ) + : AlbumItemListTile( + item: mutableAlbum, + onTap: onTap, + parentType: widget.parentType, + ), + ), + ); + } +} diff --git a/lib/components/MusicScreen/album_item_card.dart b/lib/components/MusicScreen/album_item_card.dart new file mode 100644 index 0000000..985696f --- /dev/null +++ b/lib/components/MusicScreen/album_item_card.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; + +import '../../models/jellyfin_models.dart'; +import '../../models/finamp_models.dart'; +import '../../services/finamp_settings_helper.dart'; +import '../../services/generate_subtitle.dart'; +import '../album_image.dart'; + +/// Card content for AlbumItem. You probably shouldn't use this widget directly, +/// use AlbumItem instead. +class AlbumItemCard extends StatelessWidget { + const AlbumItemCard({ + Key? key, + required this.item, + this.parentType, + this.onTap, + this.addSettingsListener = false, + }) : super(key: key); + + final BaseItemDto item; + final String? parentType; + final void Function()? onTap; + final bool addSettingsListener; + + @override + Widget build(BuildContext context) { + return Card( + // In AlbumItem, the OpenContainer handles padding. + margin: EdgeInsets.zero, + child: ClipRRect( + borderRadius: AlbumImage.borderRadius, + child: Stack( + children: [ + AlbumImage(item: item), + addSettingsListener + ? // We need this ValueListenableBuilder to react to changes to + // showTextOnGridView. When shown in a MusicScreen, this widget + // would refresh anyway since MusicScreen also listens to + // FinampSettings, but there may be cases where this widget is used + // elsewhere. + ValueListenableBuilder>( + valueListenable: + FinampSettingsHelper.finampSettingsListener, + builder: (_, box, __) { + if (box.get("FinampSettings")!.showTextOnGridView) { + return _AlbumItemCardText( + item: item, parentType: parentType); + } else { + // ValueListenableBuilder doesn't let us return null, so we + // return a 0-sized SizedBox. + return const SizedBox.shrink(); + } + }, + ) + : FinampSettingsHelper.finampSettings.showTextOnGridView + ? _AlbumItemCardText(item: item, parentType: parentType) + : const SizedBox.shrink(), + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + ), + ), + ) + ], + ), + ), + ); + } +} + +class _AlbumItemCardText extends StatelessWidget { + const _AlbumItemCardText({ + Key? key, + required this.item, + required this.parentType, + }) : super(key: key); + + final BaseItemDto item; + final String? parentType; + + @override + Widget build(BuildContext context) { + final subtitle = generateSubtitle(item, parentType, context); + + return Align( + alignment: Alignment.bottomCenter, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + // We fade from half transparent black to transparent so that text is visible on bright images + Colors.black.withOpacity(0.5), + Colors.transparent, + ], + ), + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Align( + alignment: Alignment.bottomLeft, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name ?? "Unknown Name", + overflow: TextOverflow.ellipsis, + maxLines: 3, + style: Theme.of(context) + .textTheme + .titleLarge! + .copyWith(color: Colors.white), + ), + if (subtitle != null) + Text( + subtitle, + style: Theme.of(context) + .textTheme + .bodySmall! + .copyWith(color: Colors.white.withOpacity(0.7)), + ) + ], + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/components/MusicScreen/album_item_list_tile.dart b/lib/components/MusicScreen/album_item_list_tile.dart new file mode 100644 index 0000000..04ea4b3 --- /dev/null +++ b/lib/components/MusicScreen/album_item_list_tile.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; + +import '../../models/jellyfin_models.dart'; +import '../../services/jellyfin_api_helper.dart'; +import '../../services/generate_subtitle.dart'; +import '../album_image.dart'; + +/// ListTile content for AlbumItem. You probably shouldn't use this widget +/// directly, use AlbumItem instead. +class AlbumItemListTile extends StatelessWidget { + const AlbumItemListTile({ + Key? key, + required this.item, + this.parentType, + this.onTap, + }) : super(key: key); + + final BaseItemDto item; + final String? parentType; + final void Function()? onTap; + + @override + Widget build(BuildContext context) { + final jellyfinApiHelper = GetIt.instance(); + final subtitle = generateSubtitle(item, parentType, context); + + return ListTile( + // This widget is used on the add to playlist screen, so we allow a custom + // onTap to be passed as an argument. + onTap: onTap, + leading: AlbumImage(item: item), + title: Text( + item.name ?? AppLocalizations.of(context)!.unknownName, + overflow: TextOverflow.ellipsis, + ), + subtitle: subtitle == null ? null : Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: jellyfinApiHelper.selectedMixAlbumIds.contains(item.id) + ? const Icon(Icons.explore) + : null, + ); + } +} diff --git a/lib/components/MusicScreen/alphabet_item_list.dart b/lib/components/MusicScreen/alphabet_item_list.dart new file mode 100644 index 0000000..ad8292e --- /dev/null +++ b/lib/components/MusicScreen/alphabet_item_list.dart @@ -0,0 +1,71 @@ +import 'package:finamp/models/jellyfin_models.dart'; +import 'package:flutter/material.dart'; + +class AlphabetList extends StatefulWidget { + final Function(String) callback; + + final String sortOrder; + + const AlphabetList({super.key, required this.callback, required this.sortOrder}); + + @override + State createState() => _AlphabetListState(); +} + +class _AlphabetListState extends State { + List alphabet = ['#'] + + List.generate(26, (int index) { + return String.fromCharCode('A'.codeUnitAt(0) + index); + }); + + + List get getAlphabet => alphabet; + + @override + void initState() { + orderTheList(alphabet); + super.initState(); + } + + + @override + void didUpdateWidget(AlphabetList oldWidget) { + orderTheList(alphabet); + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.centerRight, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + alphabet.length, + (x) => InkWell( + onTap: () { + widget.callback(alphabet[x]); + }, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 2), + child: Text( + alphabet[x].toUpperCase(), + ), + ), + ), + )), + ), + ), + ); + } + + void orderTheList(List list) { + widget.sortOrder == "Ascending" + ? list.sort() + : list.sort((a, b) => b.compareTo(a)); + } +} diff --git a/lib/components/MusicScreen/artist_item_list_tile.dart b/lib/components/MusicScreen/artist_item_list_tile.dart new file mode 100644 index 0000000..6acf943 --- /dev/null +++ b/lib/components/MusicScreen/artist_item_list_tile.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; + +import '../../models/jellyfin_models.dart'; +import '../../screens/artist_screen.dart'; +import '../../services/jellyfin_api_helper.dart'; +import '../../services/finamp_settings_helper.dart'; +import '../album_image.dart'; +import '../error_snackbar.dart'; + +enum ArtistListTileMenuItems { + addToFavourite, + removeFromFavourite, + addToMixList, + removeFromMixList, +} + +class ArtistListTile extends StatefulWidget { + const ArtistListTile({ + Key? key, + required this.item, + this.index, + this.parentId, + }) : super(key: key); + + final BaseItemDto item; + final int? index; + final String? parentId; + + @override + State createState() => _ArtistListTileState(); +} + +class _ArtistListTileState extends State { + final _jellyfinApiHelper = GetIt.instance(); + + late BaseItemDto mutableItem = widget.item; + + @override + Widget build(BuildContext context) { + final screenSize = MediaQuery.of(context).size; + final listTile = ListTile( + onTap: () { + Navigator.of(context) + .pushNamed(ArtistScreen.routeName, arguments: mutableItem); + }, + leading: AlbumImage(item: mutableItem), + title: Text( + mutableItem.name ?? "Unknown Name", + overflow: TextOverflow.ellipsis, + ), + subtitle: null, + trailing: + _jellyfinApiHelper.selectedMixArtistsIds.contains(mutableItem.id) + ? const Icon(Icons.explore) + : null, + ); + + return GestureDetector( + onLongPressStart: (details) async { + Feedback.forLongPress(context); + // Some options are disabled in offline mode + final isOffline = FinampSettingsHelper.finampSettings.isOffline; + + final selection = await showMenu( + context: context, + position: RelativeRect.fromLTRB( + details.globalPosition.dx, + details.globalPosition.dy, + screenSize.width - details.globalPosition.dx, + screenSize.height - details.globalPosition.dy, + ), + items: [ + mutableItem.userData!.isFavorite + ? const PopupMenuItem( + value: ArtistListTileMenuItems.removeFromFavourite, + child: ListTile( + leading: Icon(Icons.favorite_border), + title: Text("Remove Favourite"), + ), + ) + : const PopupMenuItem( + value: ArtistListTileMenuItems.addToFavourite, + child: ListTile( + leading: Icon(Icons.favorite), + title: Text("Add Favourite"), + ), + ), + _jellyfinApiHelper.selectedMixArtistsIds.contains(mutableItem.id) + ? PopupMenuItem( + enabled: !isOffline, + value: ArtistListTileMenuItems.removeFromMixList, + child: ListTile( + leading: const Icon(Icons.explore_off), + title: const Text("Remove From Mix"), + enabled: isOffline ? false : true, + ), + ) + : PopupMenuItem( + value: ArtistListTileMenuItems.addToMixList, + enabled: !isOffline, + child: ListTile( + leading: const Icon(Icons.explore), + title: const Text("Add To Mix"), + enabled: !isOffline, + ), + ), + ], + ); + + if (!mounted) return; + + switch (selection) { + case ArtistListTileMenuItems.addToFavourite: + try { + final newUserData = + await _jellyfinApiHelper.addFavourite(mutableItem.id); + + if (!mounted) return; + + setState(() { + mutableItem.userData = newUserData; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Favourite added."))); + } catch (e) { + errorSnackbar(e, context); + } + break; + case ArtistListTileMenuItems.removeFromFavourite: + try { + final newUserData = + await _jellyfinApiHelper.removeFavourite(mutableItem.id); + + if (!mounted) return; + + setState(() { + mutableItem.userData = newUserData; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Favourite removed."))); + } catch (e) { + errorSnackbar(e, context); + } + break; + case ArtistListTileMenuItems.addToMixList: + try { + _jellyfinApiHelper.addArtistToMixBuilderList(mutableItem); + setState(() {}); + } catch (e) { + errorSnackbar(e, context); + } + break; + case ArtistListTileMenuItems.removeFromMixList: + try { + _jellyfinApiHelper.removeArtistFromBuilderList(mutableItem); + setState(() {}); + } catch (e) { + errorSnackbar(e, context); + } + break; + case null: + break; + } + }, + child: listTile); + } +} diff --git a/lib/components/MusicScreen/music_screen_drawer.dart b/lib/components/MusicScreen/music_screen_drawer.dart new file mode 100644 index 0000000..674c183 --- /dev/null +++ b/lib/components/MusicScreen/music_screen_drawer.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../services/finamp_user_helper.dart'; +import '../../screens/downloads_screen.dart'; +import '../../screens/logs_screen.dart'; +import '../../screens/settings_screen.dart'; +import 'offline_mode_switch_list_tile.dart'; +import 'view_list_tile.dart'; + +class MusicScreenDrawer extends StatelessWidget { + const MusicScreenDrawer({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final finampUserHelper = GetIt.instance(); + return Drawer( + child: Scrollbar( + child: CustomScrollView( + slivers: [ + SliverList( + delegate: SliverChildListDelegate.fixed( + [ + DrawerHeader( + child: Stack( + children: [ + const Align( + alignment: Alignment.topCenter, + child: CircleAvatar( + backgroundColor: Colors.transparent, + backgroundImage: AssetImage( + 'images/finamp.png', + ), + radius: 50.0, + ), + ), + Align( + alignment: + Alignment.bottomCenter - const Alignment(0, 0.2), + child: Text( + AppLocalizations.of(context)!.finamp, + style: const TextStyle(fontSize: 20), + )), + ], + )), + ListTile( + leading: const Icon(Icons.file_download), + title: Text(AppLocalizations.of(context)!.downloads), + onTap: () => Navigator.of(context) + .pushNamed(DownloadsScreen.routeName), + ), + const OfflineModeSwitchListTile(), + const Divider(), + ], + ), + ), + // This causes an error when logging out if we show this widget + if (finampUserHelper.currentUser != null) + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + return ViewListTile( + view: finampUserHelper.currentUser!.views.values + .elementAt(index)); + }, childCount: finampUserHelper.currentUser!.views.length), + ), + SliverFillRemaining( + hasScrollBody: false, + child: SafeArea( + child: Align( + alignment: Alignment.bottomCenter, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Divider(), + ListTile( + leading: const Icon(Icons.science), + title: Text(AppLocalizations.of(context)!.redesignBeta), + onTap: () async => await launchUrl(Uri.parse( + "https://github.com/jmshrv/finamp/releases/tag/0.9.2-beta")), + ), + ListTile( + leading: const Icon(Icons.warning), + title: Text(AppLocalizations.of(context)!.logs), + onTap: () => Navigator.of(context) + .pushNamed(LogsScreen.routeName), + ), + ListTile( + leading: const Icon(Icons.settings), + title: Text(AppLocalizations.of(context)!.settings), + onTap: () => Navigator.of(context) + .pushNamed(SettingsScreen.routeName), + ), + ], + ), + ), + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/components/MusicScreen/music_screen_tab_view.dart b/lib/components/MusicScreen/music_screen_tab_view.dart new file mode 100644 index 0000000..623fa0a --- /dev/null +++ b/lib/components/MusicScreen/music_screen_tab_view.dart @@ -0,0 +1,614 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:finamp/components/MusicScreen/artist_item_list_tile.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:hive/hive.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; + +import '../../models/finamp_models.dart'; +import '../../models/jellyfin_models.dart'; +import '../../services/downloads_helper.dart'; +import '../../services/finamp_settings_helper.dart'; +import '../../services/finamp_user_helper.dart'; +import '../../services/jellyfin_api_helper.dart'; +import '../AlbumScreen/song_list_tile.dart'; +import '../error_snackbar.dart'; +import '../first_page_progress_indicator.dart'; +import '../new_page_progress_indicator.dart'; +import 'album_item.dart'; +import 'alphabet_item_list.dart'; + +class MusicScreenTabView extends StatefulWidget { + const MusicScreenTabView({ + Key? key, + required this.tabContentType, + this.parentItem, + this.searchTerm, + required this.isFavourite, + this.sortBy, + this.sortOrder, + this.view, + this.albumArtist, + }) : super(key: key); + + final TabContentType tabContentType; + final BaseItemDto? parentItem; + final String? searchTerm; + final bool isFavourite; + final SortBy? sortBy; + final SortOrder? sortOrder; + final BaseItemDto? view; + final String? albumArtist; + + @override + State createState() => _MusicScreenTabViewState(); +} + +// We use AutomaticKeepAliveClientMixin so that the view keeps its position after the tab is changed. +// https://stackoverflow.com/questions/49439047/how-to-preserve-widget-states-in-flutter-when-navigating-using-bottomnavigation +class _MusicScreenTabViewState extends State + with AutomaticKeepAliveClientMixin { + // If parentItem is null, we assume that this view is actually in a tab. + // If it isn't null, this view is being used as an artist detail screen and shouldn't be kept alive. + @override + bool get wantKeepAlive => widget.parentItem == null; + + static const _pageSize = 100; + + final PagingController _pagingController = + PagingController(firstPageKey: 0); + + List? offlineSortedItems; + + final _jellyfinApiHelper = GetIt.instance(); + final _finampUserHelper = GetIt.instance(); + + String? _lastSearch; + bool? _oldIsFavourite; + SortBy? _oldSortBy; + SortOrder? _oldSortOrder; + BaseItemDto? _oldView; + ScrollController? controller; + String? letterToSearch; + String lastSortOrder = SortOrder.ascending.toString(); + Timer? timer; + + // This function just lets us easily set stuff to the getItems call we want. + Future _getPage(int pageKey) async { + try { + final sortOrder = + widget.sortOrder?.toString() ?? SortOrder.ascending.toString(); + final newItems = await _jellyfinApiHelper.getItems( + // starting with Jellyfin 10.9, only automatically created playlists will have a specific library as parent. user-created playlists will not be returned anymore + // this condition fixes this by not providing a parentId when fetching playlists + parentItem: widget.tabContentType == TabContentType.playlists + ? null + : widget.parentItem ?? + widget.view ?? + _finampUserHelper.currentUser?.currentView, + includeItemTypes: _includeItemTypes(widget.tabContentType), + + // If we're on the songs tab, sort by "Album,SortName". This is what the + // Jellyfin web client does. If this isn't the case, check if parentItem + // is null. parentItem will be null when this widget is not used in an + // artist view. If it's null, sort by "SortName". If it isn't null, check + // if the parentItem is a MusicArtist. If it is, sort by year. Otherwise, + // sort by SortName. If widget.sortBy is set, it is used instead. + sortBy: widget.sortBy?.jellyfinName(widget.tabContentType) ?? + (widget.tabContentType == TabContentType.songs + ? "Album,SortName" + : widget.parentItem == null + ? "SortName" + : widget.parentItem?.type == "MusicArtist" + ? "ProductionYear,PremiereDate" + : "SortName"), + sortOrder: sortOrder, + searchTerm: widget.searchTerm?.trim(), + // If this is the genres tab, tell getItems to get genres. + isGenres: widget.tabContentType == TabContentType.genres, + filters: widget.isFavourite ? "IsFavorite" : null, + startIndex: pageKey, + limit: _pageSize, + ); + + if (newItems!.length < _pageSize) { + _pagingController.appendLastPage(newItems); + } else { + _pagingController.appendPage(newItems, pageKey + newItems.length); + } + if (letterToSearch != null) { + scrollToLetter(letterToSearch); + timer?.cancel(); + timer = Timer(const Duration(seconds: 2, milliseconds: 500), () { + scrollToNearbyLetter(); + }); + } + setState(() { + lastSortOrder = sortOrder; + }); + } catch (e) { + errorSnackbar(e, context); + } + } + + String _getParentType() => + widget.parentItem?.type! ?? + _finampUserHelper.currentUser!.currentView!.type!; + + @override + void initState() { + _pagingController.addPageRequestListener((pageKey) { + _getPage(pageKey); + }); + lastSortOrder = + widget.sortOrder?.toString() ?? SortOrder.ascending.toString(); + controller = ScrollController(); + super.initState(); + } + + @override + void didUpdateWidget(oldWidget) { + setState(() { + lastSortOrder = + widget.sortOrder?.toString() ?? SortOrder.ascending.toString(); + }); + super.didUpdateWidget(oldWidget); + } + + // Scrolls the list to the first occurrence of the letter in the list + // If clicked in the # element, it goes to the first one ( pixels = 0 ) + void scrollToLetter(String? clickedLetter) async { + String? letter = clickedLetter ?? letterToSearch; + if (letter == null) return; + + letterToSearch = letter; + + if (letter == '#') { + double targetScroll = lastSortOrder == SortOrder.ascending.toString() + ? -(controller!.position.maxScrollExtent * 10) + : controller!.position.maxScrollExtent * 10; + + await controller?.animateTo(targetScroll, + duration: const Duration(milliseconds: 200), curve: Curves.ease); + } else { + final indexWhere = _pagingController.itemList!.indexWhere((element) { + final name = element.name!; + final firstLetter = + name.startsWith(RegExp(r'^the', caseSensitive: false)) + ? name.split(RegExp(r'^the', caseSensitive: false))[1].trim()[0] + : name[0].toUpperCase(); + return firstLetter == letter; + }); + + if (indexWhere >= 0) { + final scrollTo = (indexWhere * 72).toDouble(); + await controller?.animateTo(scrollTo, + duration: const Duration(milliseconds: 200), curve: Curves.ease); + letterToSearch = null; + } else { + await controller?.animateTo(controller!.position.maxScrollExtent * 100, + duration: const Duration(milliseconds: 200), curve: Curves.ease); + } + } + } + + void scrollToNearbyLetter() { + if (letterToSearch != null) { + const standardAlphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + final closestLetterIndex = standardAlphabet.indexOf(letterToSearch!); + if (closestLetterIndex != -1) { + for (int offset = 0; offset <= standardAlphabet.length; offset++) { + for (final direction in [1, -1]) { + final nextIndex = closestLetterIndex + offset * direction; + if (nextIndex >= 0 && nextIndex < standardAlphabet.length) { + final nextLetter = standardAlphabet[nextIndex]; + final nextLetterIndex = + _pagingController.itemList!.indexWhere((element) { + final firstLetter = element.name![0].toUpperCase(); + return firstLetter == nextLetter; + }); + + if (nextLetterIndex >= 0) { + final scrollTo = (nextLetterIndex * 72).toDouble(); + controller?.animateTo(scrollTo, + duration: const Duration(milliseconds: 200), + curve: Curves.ease); + letterToSearch = null; + return; + } + } + } + } + } + } + } + + @override + void dispose() { + _pagingController.dispose(); + timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (context, box, _) { + final isOffline = box.get("FinampSettings")?.isOffline ?? false; + + if (isOffline) { + // We do the same checks we do when online to ensure that the list is + // not resorted when it doesn't have to be. + if (widget.searchTerm != _lastSearch || + offlineSortedItems == null || + widget.isFavourite != _oldIsFavourite || + widget.sortBy != _oldSortBy || + widget.sortOrder != _oldSortOrder || + widget.view != _oldView) { + _lastSearch = widget.searchTerm; + _oldIsFavourite = widget.isFavourite; + _oldSortBy = widget.sortBy; + _oldSortOrder = widget.sortOrder; + _oldView = widget.view; + + DownloadsHelper downloadsHelper = GetIt.instance(); + + if (widget.tabContentType == TabContentType.artists) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.cloud_off, + size: 64, + color: Colors.white.withOpacity(0.5), + ), + const Padding(padding: EdgeInsets.all(8.0)), + const Text("Offline artists view hasn't been implemented") + ], + ), + ); + } + + if (widget.searchTerm == null) { + if (widget.tabContentType == TabContentType.songs) { + // If we're on the songs tab, just get all of the downloaded items + offlineSortedItems = downloadsHelper.downloadedItems + .where((element) => + element.viewId == + _finampUserHelper.currentUser!.currentViewId) + .map((e) => e.song) + .toList(); + } else { + String? albumArtist = widget.albumArtist; + offlineSortedItems = downloadsHelper.downloadedParents + .where((element) => + element.item.type == + _includeItemTypes(widget.tabContentType) && + element.viewId == + _finampUserHelper.currentUser!.currentViewId && + (albumArtist == null || + element.item.albumArtist?.toLowerCase() == + albumArtist.toLowerCase())) + .map((e) => e.item) + .toList(); + } + } else { + if (widget.tabContentType == TabContentType.songs) { + offlineSortedItems = downloadsHelper.downloadedItems + .where( + (element) { + return _offlineSearch( + item: element.song, + searchTerm: widget.searchTerm!, + tabContentType: widget.tabContentType); + }, + ) + .map((e) => e.song) + .toList(); + } else { + offlineSortedItems = downloadsHelper.downloadedParents + .where( + (element) { + return _offlineSearch( + item: element.item, + searchTerm: widget.searchTerm!, + tabContentType: widget.tabContentType); + }, + ) + .map((e) => e.item) + .toList(); + } + } + + offlineSortedItems!.sort((a, b) { + // if (a.name == null || b.name == null) { + // // Returning 0 is the same as both being the same + // return 0; + // } else { + // return a.name!.compareTo(b.name!); + // } + if (a.nameForSorting == null || b.nameForSorting == null) { + // Returning 0 is the same as both being the same + return 0; + } else { + switch (widget.sortBy) { + case SortBy.sortName: + if (a.nameForSorting == null || b.nameForSorting == null) { + // Returning 0 is the same as both being the same + return 0; + } else { + return a.nameForSorting!.compareTo(b.nameForSorting!); + } + case SortBy.albumArtist: + if (a.albumArtist == null || b.albumArtist == null) { + return 0; + } else { + return a.albumArtist!.compareTo(b.albumArtist!); + } + case SortBy.communityRating: + if (a.communityRating == null || + b.communityRating == null) { + return 0; + } else { + return a.communityRating!.compareTo(b.communityRating!); + } + case SortBy.criticRating: + if (a.criticRating == null || b.criticRating == null) { + return 0; + } else { + return a.criticRating!.compareTo(b.criticRating!); + } + case SortBy.dateCreated: + if (a.dateCreated == null || b.dateCreated == null) { + return 0; + } else { + return a.dateCreated!.compareTo(b.dateCreated!); + } + case SortBy.premiereDate: + if (a.premiereDate == null || b.premiereDate == null) { + return 0; + } else { + return a.premiereDate!.compareTo(b.premiereDate!); + } + case SortBy.random: + // We subtract the result by one so that we can get -1 values + // (see comareTo documentation) + return Random().nextInt(2) - 1; + default: + throw UnimplementedError( + "Unimplemented offline sort mode ${widget.sortBy}"); + } + } + }); + + if (widget.sortOrder == SortOrder.descending) { + // The above sort functions sort in ascending order, so we swap them + // when sorting in descending order. + offlineSortedItems = offlineSortedItems!.reversed.toList(); + } + } + + return Scrollbar( + controller: controller, + child: Stack( + children: [ + box.get("FinampSettings")!.contentViewType == + ContentViewType.list + ? ListView.builder( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + itemCount: offlineSortedItems!.length, + key: UniqueKey(), + controller: controller, + itemBuilder: (context, index) { + if (widget.tabContentType == TabContentType.songs) { + return SongListTile( + item: offlineSortedItems![index], + isSong: true, + ); + } else { + return AlbumItem( + album: offlineSortedItems![index], + parentType: _getParentType(), + ); + } + }, + ) + : GridView.builder( + itemCount: offlineSortedItems!.length, + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + controller: controller, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: MediaQuery.of(context).size.width > + MediaQuery.of(context).size.height + ? box + .get("FinampSettings")! + .contentGridViewCrossAxisCountLandscape + : box + .get("FinampSettings")! + .contentGridViewCrossAxisCountPortrait, + ), + itemBuilder: (context, index) { + if (widget.tabContentType == TabContentType.songs) { + return SongListTile( + item: offlineSortedItems![index], + isSong: true, + ); + } else { + return AlbumItem( + album: offlineSortedItems![index], + parentType: _getParentType(), + isGrid: true, + gridAddSettingsListener: false, + ); + } + }, + ), + box.get("FinampSettings")!.showFastScroller && + widget.sortBy == SortBy.sortName + ? AlphabetList( + callback: scrollToLetter, sortOrder: lastSortOrder) + : const SizedBox.shrink(), + ], + ), + ); + } else { + // If the searchTerm argument is different to lastSearch, the user has changed their search input. + // This makes albumViewFuture search again so that results with the search are shown. + // This also means we don't redo a search unless we actaully need to. + if (widget.searchTerm != _lastSearch || + _pagingController.itemList == null || + widget.isFavourite != _oldIsFavourite || + widget.sortBy != _oldSortBy || + widget.sortOrder != _oldSortOrder || + widget.view != _oldView) { + _lastSearch = widget.searchTerm; + _oldIsFavourite = widget.isFavourite; + _oldSortBy = widget.sortBy; + _oldSortOrder = widget.sortOrder; + _oldView = widget.view; + _pagingController.refresh(); + } + + return RefreshIndicator( + // RefreshIndicator wants an async function, so we use Future.sync() + // to run refresh() inside an async function + onRefresh: () => Future.sync(() => _pagingController.refresh()), + child: Scrollbar( + controller: controller, + child: Stack( + children: [ + box.get("FinampSettings")!.contentViewType == + ContentViewType.list + ? PagedListView.separated( + pagingController: _pagingController, + scrollController: controller, + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + builderDelegate: + PagedChildBuilderDelegate( + itemBuilder: (context, item, index) { + if (widget.tabContentType == + TabContentType.songs) { + return SongListTile( + item: item, + isSong: true, + ); + } else if (widget.tabContentType == + TabContentType.artists) { + return ArtistListTile(item: item); + } else { + return AlbumItem( + album: item, + parentType: _getParentType(), + ); + } + }, + firstPageProgressIndicatorBuilder: (_) => + const FirstPageProgressIndicator(), + newPageProgressIndicatorBuilder: (_) => + const NewPageProgressIndicator(), + ), + separatorBuilder: (context, index) => SizedBox( + height: widget.tabContentType == + TabContentType.artists || + widget.tabContentType == + TabContentType.genres + ? 16.0 + : 0.0, + ), + ) + : PagedGridView( + pagingController: _pagingController, + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + scrollController: controller, + builderDelegate: + PagedChildBuilderDelegate( + itemBuilder: (context, item, index) { + if (widget.tabContentType == + TabContentType.songs) { + return SongListTile( + item: item, + isSong: true, + ); + } else { + return AlbumItem( + album: item, + parentType: _getParentType(), + isGrid: true, + gridAddSettingsListener: false, + ); + } + }, + firstPageProgressIndicatorBuilder: (_) => + const FirstPageProgressIndicator(), + newPageProgressIndicatorBuilder: (_) => + const NewPageProgressIndicator(), + ), + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: MediaQuery.of(context).size.width > + MediaQuery.of(context).size.height + ? box + .get("FinampSettings")! + .contentGridViewCrossAxisCountLandscape + : box + .get("FinampSettings")! + .contentGridViewCrossAxisCountPortrait, + ), + ), + box.get("FinampSettings")!.showFastScroller && + widget.sortBy == SortBy.sortName + ? AlphabetList( + callback: scrollToLetter, sortOrder: lastSortOrder) + : const SizedBox.shrink(), + ], + ), + ), + ); + } + }, + ); + } +} + +String _includeItemTypes(TabContentType tabContentType) { + switch (tabContentType) { + case TabContentType.songs: + return "Audio"; + case TabContentType.albums: + return "MusicAlbum"; + case TabContentType.artists: + return "MusicArtist"; + case TabContentType.genres: + return "MusicGenre"; + case TabContentType.playlists: + return "Playlist"; + default: + throw const FormatException("Unsupported TabContentType"); + } +} + +bool _offlineSearch( + {required BaseItemDto item, + required String searchTerm, + required TabContentType tabContentType}) { + late bool containsName; + + // This horrible thing is for null safety + if (item.name == null) { + containsName = false; + } else { + containsName = item.name!.toLowerCase().contains(searchTerm.toLowerCase()); + } + + return item.type == _includeItemTypes(tabContentType) && containsName; +} diff --git a/lib/components/MusicScreen/offline_mode_switch_list_tile.dart b/lib/components/MusicScreen/offline_mode_switch_list_tile.dart new file mode 100644 index 0000000..ec59362 --- /dev/null +++ b/lib/components/MusicScreen/offline_mode_switch_list_tile.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hive/hive.dart'; + +import '../../services/finamp_settings_helper.dart'; +import '../../models/finamp_models.dart'; + +class OfflineModeSwitchListTile extends StatelessWidget { + const OfflineModeSwitchListTile({ + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (context, box, widget) { + return SwitchListTile.adaptive( + title: Text(AppLocalizations.of(context)!.offlineMode), + secondary: const Icon(Icons.cloud_off), + value: box.get("FinampSettings")?.isOffline ?? false, + onChanged: (value) { + FinampSettingsHelper.setIsOffline(value); + }, + ); + }, + ); + } +} diff --git a/lib/components/MusicScreen/sort_by_menu_button.dart b/lib/components/MusicScreen/sort_by_menu_button.dart new file mode 100644 index 0000000..b9d8818 --- /dev/null +++ b/lib/components/MusicScreen/sort_by_menu_button.dart @@ -0,0 +1,37 @@ +import 'package:finamp/models/finamp_models.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../../models/jellyfin_models.dart'; +import '../../services/finamp_settings_helper.dart'; + +class SortByMenuButton extends StatelessWidget { + const SortByMenuButton(this.tabType, {Key? key}) : super(key: key); + + final TabContentType tabType; + + @override + Widget build(BuildContext context) { + return PopupMenuButton( + icon: const Icon(Icons.sort), + tooltip: AppLocalizations.of(context)!.sortBy, + itemBuilder: (context) => [ + for (SortBy sortBy in SortBy.defaults) + PopupMenuItem( + value: sortBy, + child: Text( + sortBy.toLocalisedString(context), + style: TextStyle( + color: + FinampSettingsHelper.finampSettings.getTabSortBy(tabType) == + sortBy + ? Theme.of(context).colorScheme.secondary + : null, + ), + ), + ) + ], + onSelected: (value) => FinampSettingsHelper.setSortBy(tabType, value), + ); + } +} diff --git a/lib/components/MusicScreen/sort_order_button.dart b/lib/components/MusicScreen/sort_order_button.dart new file mode 100644 index 0000000..09d8cab --- /dev/null +++ b/lib/components/MusicScreen/sort_order_button.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hive/hive.dart'; + +import '../../models/jellyfin_models.dart'; +import '../../models/finamp_models.dart'; +import '../../services/finamp_settings_helper.dart'; + +class SortOrderButton extends StatelessWidget { + const SortOrderButton(this.tabType, {Key? key}) : super(key: key); + + final TabContentType tabType; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (context, box, _) { + final finampSettings = box.get("FinampSettings"); + + return IconButton( + tooltip: AppLocalizations.of(context)!.sortOrder, + icon: finampSettings!.getSortOrder(tabType) == SortOrder.ascending + ? const Icon(Icons.arrow_downward) + : const Icon(Icons.arrow_upward), + onPressed: () { + if (finampSettings.getSortOrder(tabType) == SortOrder.ascending) { + FinampSettingsHelper.setSortOrder(tabType,SortOrder.descending); + } else { + FinampSettingsHelper.setSortOrder(tabType, SortOrder.ascending); + } + }, + ); + }, + ); + } +} diff --git a/lib/components/MusicScreen/view_list_tile.dart b/lib/components/MusicScreen/view_list_tile.dart new file mode 100644 index 0000000..2c42ea9 --- /dev/null +++ b/lib/components/MusicScreen/view_list_tile.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; + +import '../../models/jellyfin_models.dart'; +import '../../services/finamp_user_helper.dart'; +import '../view_icon.dart'; + +class ViewListTile extends StatelessWidget { + const ViewListTile({Key? key, required this.view}) : super(key: key); + + final BaseItemDto view; + + @override + Widget build(BuildContext context) { + final finampUserHelper = GetIt.instance(); + + return ListTile( + leading: ViewIcon( + collectionType: view.collectionType, + color: finampUserHelper.currentUser!.currentViewId == view.id + ? Theme.of(context).colorScheme.primary + : null, + ), + title: Text( + view.name ?? "Unknown Name", + style: TextStyle( + color: finampUserHelper.currentUser!.currentViewId == view.id + ? Theme.of(context).colorScheme.primary + : null, + ), + ), + onTap: () => finampUserHelper.setCurrentUserCurrentViewId(view.id), + ); + } +} \ No newline at end of file diff --git a/lib/components/PlayerScreen/add_to_playlist_button.dart b/lib/components/PlayerScreen/add_to_playlist_button.dart new file mode 100644 index 0000000..96b1586 --- /dev/null +++ b/lib/components/PlayerScreen/add_to_playlist_button.dart @@ -0,0 +1,40 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; + +import '../../models/jellyfin_models.dart'; +import '../../services/music_player_background_task.dart'; +import '../../screens/add_to_playlist_screen.dart'; + +class AddToPlaylistButton extends StatelessWidget { + const AddToPlaylistButton({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final audioHandler = GetIt.instance(); + + return StreamBuilder( + stream: audioHandler.mediaItem, + builder: (context, snapshot) { + if (snapshot.hasData) { + return IconButton( + onPressed: () => Navigator.of(context).pushReplacementNamed( + AddToPlaylistScreen.routeName, + arguments: + BaseItemDto.fromJson(snapshot.data!.extras!["itemJson"]) + .id), + icon: const Icon(Icons.playlist_add), + tooltip: AppLocalizations.of(context)!.addToPlaylistTooltip, + ); + } else { + return IconButton( + icon: const Icon(Icons.playlist_add), + onPressed: null, + tooltip: AppLocalizations.of(context)!.addToPlaylistTooltip, + ); + } + }, + ); + } +} diff --git a/lib/components/PlayerScreen/playback_mode.dart b/lib/components/PlayerScreen/playback_mode.dart new file mode 100644 index 0000000..50f6b9f --- /dev/null +++ b/lib/components/PlayerScreen/playback_mode.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:audio_service/audio_service.dart'; +import 'package:get_it/get_it.dart'; + +import '../../services/music_player_background_task.dart'; + +class PlaybackMode extends StatelessWidget { + const PlaybackMode({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final audioHandler = GetIt.instance(); + + return StreamBuilder( + stream: audioHandler.mediaItem, + builder: (context, snapshot) { + if (snapshot.hasData) { + late String onlineOrOffline; + late String transcodeOrDirect; + if (snapshot.data!.extras!["downloadedSongJson"] == null) { + onlineOrOffline = AppLocalizations.of(context)!.streaming; + } else { + onlineOrOffline = AppLocalizations.of(context)!.downloaded; + } + + if (snapshot.data!.extras!["shouldTranscode"] && + snapshot.data!.extras!["downloadedSongJson"] == null) { + transcodeOrDirect = AppLocalizations.of(context)!.transcode; + } else { + transcodeOrDirect = AppLocalizations.of(context)!.direct; + } + + return Text( + "$onlineOrOffline\n$transcodeOrDirect", + style: Theme.of(context).textTheme.bodySmall, + ); + } else if (snapshot.hasError) { + return Text( + AppLocalizations.of(context)!.statusError, + style: Theme.of(context).textTheme.bodySmall, + ); + } else { + return Text( + AppLocalizations.of(context)!.noItem.toUpperCase(), + style: Theme.of(context).textTheme.bodySmall, + ); + } + }, + ); + } +} diff --git a/lib/components/PlayerScreen/player_buttons.dart b/lib/components/PlayerScreen/player_buttons.dart new file mode 100644 index 0000000..0ec1082 --- /dev/null +++ b/lib/components/PlayerScreen/player_buttons.dart @@ -0,0 +1,146 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../../services/music_player_background_task.dart'; + +class PlayerButtons extends StatelessWidget { + const PlayerButtons({Key? key}) : super(key: key); + @override + Widget build(BuildContext context) { + final audioHandler = GetIt.instance(); + + return StreamBuilder( + stream: audioHandler.playbackState, + builder: (context, snapshot) { + final PlaybackState? playbackState = snapshot.data; + return Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + textDirection: TextDirection.ltr, + children: [ + IconButton( + tooltip: playbackState?.shuffleMode == AudioServiceShuffleMode.all + ? AppLocalizations.of(context)!.playbackOrderShuffledTooltip + : AppLocalizations.of(context)!.playbackOrderLinearTooltip, + icon: _getShufflingIcon( + playbackState == null + ? AudioServiceShuffleMode.none + : playbackState.shuffleMode, + Theme.of(context).colorScheme.secondary, + ), + onPressed: playbackState != null + ? () async { + if (playbackState.shuffleMode == + AudioServiceShuffleMode.all) { + await audioHandler + .setShuffleMode(AudioServiceShuffleMode.none); + } else { + await audioHandler + .setShuffleMode(AudioServiceShuffleMode.all); + } + } + : null, + iconSize: 20, + ), + IconButton( + tooltip: AppLocalizations.of(context)!.skipToPrevious, + icon: const Icon(Icons.skip_previous), + onPressed: playbackState != null + ? () async => await audioHandler.skipToPrevious() + : null, + iconSize: 36, + ), + SizedBox( + height: 56, + width: 56, + child: FloatingActionButton( + tooltip: AppLocalizations.of(context)!.togglePlayback, + // We set a heroTag because otherwise the play button on AlbumScreenContent will do hero widget stuff + heroTag: "PlayerScreenFAB", + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + splashColor: + Theme.of(context).colorScheme.onPrimary.withOpacity(0.24), + onPressed: playbackState != null + ? () async { + if (playbackState.playing) { + await audioHandler.pause(); + } else { + await audioHandler.play(); + } + } + : null, + child: Icon( + playbackState == null || playbackState.playing + ? Icons.pause + : Icons.play_arrow, + size: 36, + ), + ), + ), + IconButton( + tooltip: AppLocalizations.of(context)!.skipToNext, + icon: const Icon(Icons.skip_next), + onPressed: playbackState != null + ? () async => audioHandler.skipToNext() + : null, + iconSize: 36), + IconButton( + tooltip: playbackState?.repeatMode == AudioServiceRepeatMode.all + ? AppLocalizations.of(context)!.loopModeAllTooltip + : playbackState?.repeatMode == AudioServiceRepeatMode.one + ? AppLocalizations.of(context)!.loopModeOneTooltip + : AppLocalizations.of(context)!.loopModeNoneTooltip, + icon: _getRepeatingIcon( + playbackState == null + ? AudioServiceRepeatMode.none + : playbackState.repeatMode, + Theme.of(context).colorScheme.secondary, + ), + onPressed: playbackState != null + ? () async { + // Cyles from none -> all -> one + if (playbackState.repeatMode == + AudioServiceRepeatMode.none) { + await audioHandler + .setRepeatMode(AudioServiceRepeatMode.all); + } else if (playbackState.repeatMode == + AudioServiceRepeatMode.all) { + await audioHandler + .setRepeatMode(AudioServiceRepeatMode.one); + } else { + await audioHandler + .setRepeatMode(AudioServiceRepeatMode.none); + } + } + : null, + iconSize: 20, + ), + ], + ); + }, + ); + } + + Widget _getRepeatingIcon( + AudioServiceRepeatMode repeatMode, Color iconColour) { + if (repeatMode == AudioServiceRepeatMode.all) { + return Icon(Icons.repeat, color: iconColour); + } else if (repeatMode == AudioServiceRepeatMode.one) { + return Icon(Icons.repeat_one, color: iconColour); + } else { + return const Icon(Icons.repeat); + } + } + + Icon _getShufflingIcon( + AudioServiceShuffleMode shuffleMode, Color iconColour) { + if (shuffleMode == AudioServiceShuffleMode.all) { + return Icon(Icons.shuffle, color: iconColour); + } else { + return const Icon(Icons.shuffle); + } + } +} diff --git a/lib/components/PlayerScreen/progress_slider.dart b/lib/components/PlayerScreen/progress_slider.dart new file mode 100644 index 0000000..3d27ac2 --- /dev/null +++ b/lib/components/PlayerScreen/progress_slider.dart @@ -0,0 +1,288 @@ +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; + +import '../../services/music_player_background_task.dart'; +import '../../services/progress_state_stream.dart'; +import '../print_duration.dart'; + +class ProgressSlider extends StatefulWidget { + const ProgressSlider({ + Key? key, + this.allowSeeking = true, + this.showBuffer = true, + this.showDuration = true, + this.showPlaceholder = true, + }) : super(key: key); + + final bool allowSeeking; + final bool showBuffer; + final bool showDuration; + final bool showPlaceholder; + + @override + State createState() => _ProgressSliderState(); +} + +class _ProgressSliderState extends State { + /// Value used to hold the slider's value when dragging. + double? _dragValue; + + late SliderThemeData _sliderThemeData; + + final _audioHandler = GetIt.instance(); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _sliderThemeData = SliderTheme.of(context).copyWith( + trackHeight: 4.0, + inactiveTrackColor: + Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5), + ); + } + + @override + Widget build(BuildContext context) { + // The slider always needs to be LTR, so we use Directionality to save + // putting TextDirection.ltr all over the place + return Directionality( + textDirection: TextDirection.ltr, + // The slider can refresh up to 60 times per second, so we wrap it in a + // RepaintBoundary to avoid more areas being repainted than necessary + child: RepaintBoundary( + child: StreamBuilder( + stream: progressStateStream, + builder: (context, snapshot) { + if (snapshot.data?.mediaItem == null) { + // If nothing is playing or the AudioService isn't connected, return a + // greyed out slider with some fake numbers. We also do this if + // currentPosition is null, which sometimes happens when the app is + // closed and reopened. + return widget.showPlaceholder + ? Column( + children: [ + SliderTheme( + data: _sliderThemeData.copyWith( + trackShape: CustomTrackShape(), + ), + child: const Slider( + value: 0, + max: 1, + onChanged: null, + ), + ), + if (widget.showDuration) + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "00:00", + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: Theme.of(context) + .textTheme + .bodySmall + ?.color), + ), + Text( + "00:00", + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: Theme.of(context) + .textTheme + .bodySmall + ?.color), + ), + ], + ), + ], + ) + : const SizedBox.shrink(); + } else if (snapshot.hasData) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Slider displaying playback and buffering progress. + SliderTheme( + data: widget.allowSeeking + ? _sliderThemeData.copyWith( + trackShape: CustomTrackShape(), + ) + : _sliderThemeData.copyWith( + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 0), + // gets rid of both horizontal and vertical padding + overlayShape: + const RoundSliderOverlayShape(overlayRadius: 0), + trackShape: const RectangularSliderTrackShape(), + ), + child: Slider( + min: 0.0, + max: snapshot.data!.mediaItem?.duration == null + ? snapshot.data!.playbackState.bufferedPosition + .inMicroseconds + .toDouble() + : snapshot.data!.mediaItem!.duration!.inMicroseconds + .toDouble(), + value: (_dragValue ?? + snapshot.data!.position.inMicroseconds) + .clamp( + 0, + snapshot.data!.mediaItem!.duration!.inMicroseconds + .toDouble()) + .toDouble(), + secondaryTrackValue: widget.showBuffer && + snapshot.data!.mediaItem + ?.extras?["downloadedSongJson"] == + null + ? snapshot.data!.playbackState.bufferedPosition + .inMicroseconds + .clamp( + 0.0, + snapshot.data!.mediaItem!.duration == null + ? snapshot.data!.playbackState + .bufferedPosition.inMicroseconds + : snapshot.data!.mediaItem!.duration! + .inMicroseconds, + ) + .toDouble() + : 0, + onChanged: widget.allowSeeking + ? (newValue) async { + // We don't actually tell audio_service to seek here + // because it would get flooded with seek requests + setState(() { + _dragValue = newValue; + }); + } + : (_) {}, + onChangeStart: widget.allowSeeking + ? (value) { + setState(() { + _dragValue = value; + }); + } + : (_) {}, + onChangeEnd: widget.allowSeeking + ? (newValue) async { + // Seek to the new position + await _audioHandler.seek( + Duration(microseconds: newValue.toInt())); + + // Clear drag value so that the slider uses the play + // duration again. + setState(() { + _dragValue = null; + }); + } + : (_) {}, + ), + ), + if (widget.showDuration) + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + printDuration( + Duration( + microseconds: _dragValue?.toInt() ?? + snapshot.data!.position.inMicroseconds), + ), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: Theme.of(context) + .textTheme + .bodySmall + ?.color), + ), + Text( + printDuration(snapshot.data!.mediaItem?.duration), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: Theme.of(context) + .textTheme + .bodySmall + ?.color), + ), + ], + ), + ], + ); + } else { + return const Text( + "Snapshot doesn't have data and MediaItem isn't null and AudioService is connected?"); + } + }, + ), + ), + ); + } +} + +class PositionData { + final Duration position; + final Duration bufferedPosition; + + PositionData(this.position, this.bufferedPosition); +} + +/// Track shape used to remove horizontal padding. +/// https://github.com/flutter/flutter/issues/37057 +class CustomTrackShape extends RoundedRectSliderTrackShape { + @override + Rect getPreferredRect({ + required RenderBox parentBox, + Offset offset = Offset.zero, + required SliderThemeData sliderTheme, + bool isEnabled = false, + bool isDiscrete = false, + }) { + final double trackHeight = sliderTheme.trackHeight!; + final double trackLeft = offset.dx; + final double trackTop = + offset.dy + (parentBox.size.height - trackHeight) / 2; + final double trackWidth = parentBox.size.width; + return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight); + } + + /// Disable additionalActiveTrackHeight + @override + void paint( + PaintingContext context, + Offset offset, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation enableAnimation, + required TextDirection textDirection, + required Offset thumbCenter, + Offset? secondaryOffset, + bool isDiscrete = false, + bool isEnabled = false, + double additionalActiveTrackHeight = 0, + }) { + super.paint( + context, + offset, + parentBox: parentBox, + sliderTheme: sliderTheme, + enableAnimation: enableAnimation, + textDirection: textDirection, + thumbCenter: thumbCenter, + secondaryOffset: secondaryOffset, + isDiscrete: isDiscrete, + isEnabled: isEnabled, + additionalActiveTrackHeight: additionalActiveTrackHeight, + ); + } +} \ No newline at end of file diff --git a/lib/components/PlayerScreen/queue_button.dart b/lib/components/PlayerScreen/queue_button.dart new file mode 100644 index 0000000..adb836b --- /dev/null +++ b/lib/components/PlayerScreen/queue_button.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'queue_list.dart'; + +class QueueButton extends StatelessWidget { + const QueueButton({ + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return IconButton( + icon: const Icon(Icons.queue_music), + tooltip: AppLocalizations.of(context)!.queue, + onPressed: () { + showModalBottomSheet( + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24.0)), + ), + context: context, + builder: (context) { + return DraggableScrollableSheet( + expand: false, + builder: (context, scrollController) { + return QueueList( + scrollController: scrollController, + ); + }, + ); + }, + ); + }); + } +} diff --git a/lib/components/PlayerScreen/queue_list.dart b/lib/components/PlayerScreen/queue_list.dart new file mode 100644 index 0000000..07e3b28 --- /dev/null +++ b/lib/components/PlayerScreen/queue_list.dart @@ -0,0 +1,115 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; +import 'package:rxdart/rxdart.dart'; + +import '../../services/finamp_settings_helper.dart'; +import '../album_image.dart'; +import '../../models/jellyfin_models.dart'; +import '../../services/process_artist.dart'; +import '../../services/media_state_stream.dart'; +import '../../services/music_player_background_task.dart'; + +class _QueueListStreamState { + _QueueListStreamState( + this.queue, + this.mediaState, + ); + + final List? queue; + final MediaState mediaState; +} + +class QueueList extends StatefulWidget { + const QueueList({Key? key, required this.scrollController}) : super(key: key); + + final ScrollController scrollController; + + @override + State createState() => _QueueListState(); +} + +class _QueueListState extends State { + final _audioHandler = GetIt.instance(); + List? _queue; + + @override + Widget build(BuildContext context) { + return StreamBuilder<_QueueListStreamState>( + // stream: AudioService.queueStream, + stream: Rx.combineLatest2?, MediaState, + _QueueListStreamState>(_audioHandler.queue, mediaStateStream, + (a, b) => _QueueListStreamState(a, b)), + builder: (context, snapshot) { + if (snapshot.hasData) { + _queue ??= snapshot.data!.queue; + return PrimaryScrollController( + controller: widget.scrollController, + child: ReorderableListView.builder( + itemCount: snapshot.data!.queue?.length ?? 0, + onReorder: (oldIndex, newIndex) async { + setState(() { + // _queue?.insert(newIndex, _queue![oldIndex]); + // _queue?.removeAt(oldIndex); + int? smallerThanNewIndex; + if (oldIndex < newIndex) { + // When we're moving an item backwards, we need to reduce + // newIndex by 1 to account for there being a new item added + // before newIndex. + smallerThanNewIndex = newIndex - 1; + } + final item = _queue?.removeAt(oldIndex); + _queue?.insert(smallerThanNewIndex ?? newIndex, item!); + }); + await _audioHandler.reorderQueue(oldIndex, newIndex); + }, + itemBuilder: (context, index) { + final actualIndex = + _audioHandler.playbackState.valueOrNull?.shuffleMode == + AudioServiceShuffleMode.all + ? _audioHandler.shuffleIndices![index] + : index; + return Dismissible( + key: ValueKey(snapshot.data!.queue![actualIndex].id), + direction: FinampSettingsHelper.finampSettings.disableGesture + ? DismissDirection.none + : DismissDirection.horizontal, + onDismissed: (direction) async { + await _audioHandler.removeQueueItemAt(actualIndex); + }, + child: ListTile( + leading: AlbumImage( + item: snapshot.data!.queue?[actualIndex] + .extras?["itemJson"] == + null + ? null + : BaseItemDto.fromJson(snapshot + .data!.queue?[actualIndex].extras?["itemJson"]), + ), + title: Text( + snapshot.data!.queue?[actualIndex].title ?? + AppLocalizations.of(context)!.unknownName, + style: snapshot.data!.mediaState.mediaItem == + snapshot.data!.queue?[actualIndex] + ? TextStyle( + color: Theme.of(context).colorScheme.secondary) + : null), + subtitle: Text(processArtist( + snapshot.data!.queue?[actualIndex].artist, context)), + onTap: () async => + await _audioHandler.skipToIndex(actualIndex), + ), + ); + }, + ), + ); + } else { + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + } + }, + ); + } +} diff --git a/lib/components/PlayerScreen/sleep_timer_button.dart b/lib/components/PlayerScreen/sleep_timer_button.dart new file mode 100644 index 0000000..a217801 --- /dev/null +++ b/lib/components/PlayerScreen/sleep_timer_button.dart @@ -0,0 +1,42 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; + +import '../../services/music_player_background_task.dart'; +import 'sleep_timer_dialog.dart'; +import 'sleep_timer_cancel_dialog.dart'; + +class SleepTimerButton extends StatelessWidget { + const SleepTimerButton({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final audioHandler = GetIt.instance(); + + return ValueListenableBuilder( + valueListenable: audioHandler.sleepTimer, + builder: (context, value, child) { + return IconButton( + icon: value == null + ? const Icon(Icons.mode_night_outlined) + : const Icon(Icons.mode_night), + tooltip: AppLocalizations.of(context)!.sleepTimerTooltip, + onPressed: () async { + if (value != null) { + showDialog( + context: context, + builder: (context) => const SleepTimerCancelDialog(), + ); + } else { + await showDialog( + context: context, + builder: (context) => const SleepTimerDialog(), + ); + } + }); + }, + ); + } +} diff --git a/lib/components/PlayerScreen/sleep_timer_cancel_dialog.dart b/lib/components/PlayerScreen/sleep_timer_cancel_dialog.dart new file mode 100644 index 0000000..ef7c3a3 --- /dev/null +++ b/lib/components/PlayerScreen/sleep_timer_cancel_dialog.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; + +import '../../services/music_player_background_task.dart'; + +class SleepTimerCancelDialog extends StatelessWidget { + const SleepTimerCancelDialog({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final audioHandler = GetIt.instance(); + + return AlertDialog( + title: Text(AppLocalizations.of(context)!.cancelSleepTimer), + actions: [ + TextButton( + child: Text(AppLocalizations.of(context)!.noButtonLabel), + onPressed: () => Navigator.of(context).pop(), + ), + TextButton( + child: Text(AppLocalizations.of(context)!.yesButtonLabel), + onPressed: () { + audioHandler.clearSleepTimer(); + Navigator.of(context).pop(); + }, + ) + ], + ); + } +} diff --git a/lib/components/PlayerScreen/sleep_timer_dialog.dart b/lib/components/PlayerScreen/sleep_timer_dialog.dart new file mode 100644 index 0000000..208c435 --- /dev/null +++ b/lib/components/PlayerScreen/sleep_timer_dialog.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; + +import '../../services/music_player_background_task.dart'; + +import '../../services/finamp_settings_helper.dart'; + +class SleepTimerDialog extends StatefulWidget { + const SleepTimerDialog({Key? key}) : super(key: key); + + @override + State createState() => _SleepTimerDialogState(); +} + +class _SleepTimerDialogState extends State { + final _audioHandler = GetIt.instance(); + + final _textController = TextEditingController( + text: (FinampSettingsHelper.finampSettings.sleepTimerSeconds ~/ 60) + .toString()); + + final _formKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(AppLocalizations.of(context)!.setSleepTimer), + content: Form( + key: _formKey, + child: Row( + children: [ + Expanded( + child: TextFormField( + controller: _textController, + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.minutes), + validator: (value) { + if (value == null || value.isEmpty) { + return AppLocalizations.of(context)!.required; + } + + if (int.tryParse(value) == null) { + return AppLocalizations.of(context)!.invalidNumber; + } + return null; + }, + onSaved: (value) { + final valueInt = int.parse(value!); + + _audioHandler.setSleepTimer(Duration(minutes: valueInt)); + FinampSettingsHelper.setSleepTimerSeconds(valueInt * 60); + }, + ), + ), + ], + ), + ), + actions: [ + TextButton( + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + onPressed: () => Navigator.of(context).pop(), + ), + TextButton( + child: Text(MaterialLocalizations.of(context).okButtonLabel), + onPressed: () { + if (_formKey.currentState?.validate() == true) { + _formKey.currentState!.save(); + Navigator.of(context).pop(); + } + }, + ) + ], + ); + } +} diff --git a/lib/components/PlayerScreen/song_name.dart b/lib/components/PlayerScreen/song_name.dart new file mode 100644 index 0000000..f2872d6 --- /dev/null +++ b/lib/components/PlayerScreen/song_name.dart @@ -0,0 +1,160 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:finamp/models/jellyfin_models.dart'; +import 'package:finamp/screens/artist_screen.dart'; +import 'package:finamp/services/finamp_settings_helper.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; + +import '../../screens/album_screen.dart'; +import '../../services/jellyfin_api_helper.dart'; +import '../../services/music_player_background_task.dart'; +import '../artists_text_spans.dart'; + +/// Creates some text that shows the song's name, album and the artist. +class SongName extends StatelessWidget { + const SongName({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final audioHandler = GetIt.instance(); + final JellyfinApiHelper jellyfinApiHelper = + GetIt.instance(); + + final textColour = + Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(0.6); + + return StreamBuilder( + stream: audioHandler.mediaItem, + builder: (context, snapshot) { + if (snapshot.hasData) { + final MediaItem mediaItem = snapshot.data!; + BaseItemDto songBaseItemDto = + BaseItemDto.fromJson(mediaItem.extras!["itemJson"]); + + List separatedArtistTextSpans = []; + + if (songBaseItemDto.artistItems?.isEmpty ?? true) { + separatedArtistTextSpans = [ + TextSpan( + text: AppLocalizations.of(context)!.unknownArtist, + style: TextStyle(color: textColour), + ) + ]; + } else { + songBaseItemDto.artistItems + ?.map((e) => TextSpan( + text: e.name, + style: TextStyle(color: textColour), + recognizer: TapGestureRecognizer() + ..onTap = () { + // Offline artists aren't implemented yet so we return if + // offline + if (FinampSettingsHelper.finampSettings.isOffline) { + return; + } + + jellyfinApiHelper.getItemById(e.id).then((artist) => + Navigator.of(context).popAndPushNamed( + ArtistScreen.routeName, + arguments: artist)); + })) + .forEach((artistTextSpan) { + separatedArtistTextSpans.add(artistTextSpan); + separatedArtistTextSpans.add(TextSpan( + text: ", ", + style: TextStyle(color: textColour), + )); + }); + separatedArtistTextSpans.removeLast(); + } + + return SongNameContent( + songBaseItemDto: songBaseItemDto, + mediaItem: mediaItem, + separatedArtistTextSpans: ArtistsTextSpans( + songBaseItemDto, + textColour, + context, + true, + )); + } + + return const SongNameContent( + songBaseItemDto: null, + mediaItem: null, + separatedArtistTextSpans: [], + ); + }, + ); + } +} + +class SongNameContent extends StatelessWidget { + const SongNameContent({ + Key? key, + required this.songBaseItemDto, + required this.mediaItem, + required this.separatedArtistTextSpans, + }) : super(key: key); + final BaseItemDto? songBaseItemDto; + final MediaItem? mediaItem; + final List separatedArtistTextSpans; + + @override + Widget build(BuildContext context) { + final jellyfinApiHelper = GetIt.instance(); + + final textColour = + Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(0.6); + + return Padding( + // I don't know why but 12 is the magic number that lines up with the + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: songBaseItemDto == null + ? null + : () => jellyfinApiHelper + .getItemById(songBaseItemDto!.albumId as String) + .then((album) => Navigator.of(context).popAndPushNamed( + AlbumScreen.routeName, + arguments: album)), + child: Text( + mediaItem == null + ? AppLocalizations.of(context)!.noAlbum + : mediaItem!.album ?? AppLocalizations.of(context)!.noAlbum, + style: TextStyle(color: textColour), + ), + ), + const Padding(padding: EdgeInsets.symmetric(vertical: 2)), + Text( + mediaItem == null + ? AppLocalizations.of(context)!.noItem + : mediaItem!.title, + style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600), + overflow: TextOverflow.fade, + softWrap: false, + maxLines: 1, + ), + const Padding(padding: EdgeInsets.symmetric(vertical: 2)), + RichText( + text: TextSpan( + children: mediaItem == null || mediaItem!.artist == null + ? [ + TextSpan( + text: AppLocalizations.of(context)!.noArtist, + style: TextStyle(color: textColour), + ) + ] + : separatedArtistTextSpans, + ), + ), + ], + ), + ); + } +} diff --git a/lib/components/SettingsScreen/logout_list_tile.dart b/lib/components/SettingsScreen/logout_list_tile.dart new file mode 100644 index 0000000..2a95cc6 --- /dev/null +++ b/lib/components/SettingsScreen/logout_list_tile.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; + +import '../../screens/splash_screen.dart'; +import '../../services/jellyfin_api_helper.dart'; +import '../../services/finamp_settings_helper.dart'; +import '../../services/music_player_background_task.dart'; +import '../error_snackbar.dart'; + +class LogoutListTile extends StatefulWidget { + const LogoutListTile({Key? key}) : super(key: key); + + @override + State createState() => _LogoutListTileState(); +} + +class _LogoutListTileState extends State { + @override + Widget build(BuildContext context) { + return ListTile( + leading: Icon( + Icons.logout, + color: + FinampSettingsHelper.finampSettings.isOffline ? null : Colors.red, + ), + title: Text( + AppLocalizations.of(context)!.logOut, + style: FinampSettingsHelper.finampSettings.isOffline + ? null + : const TextStyle( + color: Colors.red, + ), + ), + subtitle: FinampSettingsHelper.finampSettings.isOffline + ? Text(AppLocalizations.of(context)!.notAvailableInOfflineMode) + : Text( + AppLocalizations.of(context)!.downloadedSongsWillNotBeDeleted, + style: const TextStyle(color: Colors.red), + ), + enabled: !FinampSettingsHelper.finampSettings.isOffline, + onTap: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.areYouSure), + actions: [ + TextButton( + child: + Text(MaterialLocalizations.of(context).cancelButtonLabel), + onPressed: () => Navigator.of(context).pop(), + ), + TextButton( + child: Text(MaterialLocalizations.of(context).okButtonLabel), + onPressed: () async { + try { + final audioHandler = + GetIt.instance(); + + // We don't want audio to be playing after we log out. + // We check if the audio service is running on iOS because + // stop() never completes if the service is not running. + if (audioHandler.playbackState.valueOrNull?.playing == + true) { + await audioHandler.stop(); + } + + final jellyfinApiHelper = + GetIt.instance(); + + await jellyfinApiHelper + .logoutCurrentUser() + .onError((_, __) {}); + + if (!mounted) return; + + Navigator.of(context).pushNamedAndRemoveUntil( + SplashScreen.routeName, (route) => false); + } catch (e) { + errorSnackbar(e, context); + return; + } + }, + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/components/TabsSettingsScreen/hide_tab_toggle.dart b/lib/components/TabsSettingsScreen/hide_tab_toggle.dart new file mode 100644 index 0000000..ddae160 --- /dev/null +++ b/lib/components/TabsSettingsScreen/hide_tab_toggle.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; + +import '../../services/finamp_settings_helper.dart'; +import '../../models/finamp_models.dart'; + +class HideTabToggle extends StatelessWidget { + const HideTabToggle({ + Key? key, + required this.tabContentType, + }) : super(key: key); + + final TabContentType tabContentType; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (_, box, __) { + return SwitchListTile.adaptive( + title: Text(tabContentType.toLocalisedString(context)), + // secondary: const Icon(Icons.drag_handle), + // This should never be null, but it gets set to true if it is. + value: FinampSettingsHelper.finampSettings.showTabs[tabContentType] ?? + true, + onChanged: (value) => + FinampSettingsHelper.setShowTab(tabContentType, value), + ); + }, + ); + } +} diff --git a/lib/components/TranscodingSettingsScreen/bitrate_selector.dart b/lib/components/TranscodingSettingsScreen/bitrate_selector.dart new file mode 100644 index 0000000..602a0f3 --- /dev/null +++ b/lib/components/TranscodingSettingsScreen/bitrate_selector.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hive/hive.dart'; + +import '../../services/finamp_settings_helper.dart'; +import '../../models/finamp_models.dart'; + +class BitrateSelector extends StatelessWidget { + const BitrateSelector({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + ListTile( + title: Text(AppLocalizations.of(context)!.bitrate), + subtitle: Text(AppLocalizations.of(context)!.bitrateSubtitle), + ), + ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (context, box, child) { + final finampSettings = box.get("FinampSettings")!; + + // We do all of this division/multiplication because Jellyfin wants us to specify bitrates in bits, not kilobits. + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Slider( + min: 64, + max: 320, + value: finampSettings.transcodeBitrate / 1000, + divisions: 8, + label: "${finampSettings.transcodeBitrate ~/ 1000}kbps", + onChanged: (value) { + FinampSettingsHelper.setTranscodeBitrate( + (value * 1000).toInt()); + }, + ), + Text( + "${finampSettings.transcodeBitrate ~/ 1000}kbps", + style: Theme.of(context).textTheme.titleLarge, + ) + ], + ); + }, + ), + ], + ); + } +} diff --git a/lib/components/TranscodingSettingsScreen/transcode_switch.dart b/lib/components/TranscodingSettingsScreen/transcode_switch.dart new file mode 100644 index 0000000..5c46a85 --- /dev/null +++ b/lib/components/TranscodingSettingsScreen/transcode_switch.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hive/hive.dart'; + +import '../../services/finamp_settings_helper.dart'; +import '../../models/finamp_models.dart'; + +class TranscodeSwitch extends StatelessWidget { + const TranscodeSwitch({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (context, box, child) { + bool? shouldTranscode = box.get("FinampSettings")?.shouldTranscode; + + return SwitchListTile.adaptive( + title: Text(AppLocalizations.of(context)!.enableTranscoding), + subtitle: + Text(AppLocalizations.of(context)!.enableTranscodingSubtitle), + value: shouldTranscode ?? false, + onChanged: shouldTranscode == null + ? null + : (value) { + FinampSettings finampSettingsTemp = + box.get("FinampSettings")!; + finampSettingsTemp.shouldTranscode = value; + box.put("FinampSettings", finampSettingsTemp); + }, + ); + }, + ); + } +} diff --git a/lib/components/UserSelector/private_user_sign_in.dart b/lib/components/UserSelector/private_user_sign_in.dart new file mode 100644 index 0000000..c0e4db9 --- /dev/null +++ b/lib/components/UserSelector/private_user_sign_in.dart @@ -0,0 +1,212 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; + +import '../../screens/logs_screen.dart'; +import '../../screens/view_selector.dart'; +import '../../services/jellyfin_api_helper.dart'; +import '../error_snackbar.dart'; + +class PrivateUserSignIn extends StatefulWidget { + const PrivateUserSignIn({Key? key}) : super(key: key); + + @override + State createState() => _PrivateUserSignInState(); +} + +class _PrivateUserSignInState extends State { + bool isAuthenticating = false; + + String? baseUrl; + String? username; + String? password; + + final formKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + // This variable is for handling shifting focus when the user presses submit. + // https://stackoverflow.com/questions/52150677/how-to-shift-focus-to-next-textfield-in-flutter + final node = FocusScope.of(context); + + return SafeArea( + child: Stack( + children: [ + Form( + key: formKey, + child: AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: TextFormField( + keyboardType: TextInputType.url, + autocorrect: false, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.serverUrl, + hintText: "http://0.0.0.0:8096", + border: const OutlineInputBorder(), + suffixIcon: IconButton( + color: Theme.of(context).iconTheme.color, + icon: const Icon(Icons.info), + onPressed: () => showDialog( + context: context, + builder: (context) => AlertDialog( + content: Text(AppLocalizations.of(context)! + .internalExternalIpExplanation), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(MaterialLocalizations.of(context) + .okButtonLabel), + ) + ], + ), + ), + ), + ), + textInputAction: TextInputAction.next, + onEditingComplete: () => node.nextFocus(), + validator: (value) { + if (value?.isEmpty == true) { + return AppLocalizations.of(context)!.emptyServerUrl; + } + if (!value!.trim().startsWith("http://") && + !value.trim().startsWith("https://")) { + return AppLocalizations.of(context)! + .urlStartWithHttps; + } + if (value.trim().endsWith("/")) { + return AppLocalizations.of(context)!.urlTrailingSlash; + } + return null; + }, + onSaved: (newValue) => baseUrl = newValue, + ), + ), + Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: TextFormField( + autocorrect: false, + keyboardType: TextInputType.visiblePassword, + autofillHints: const [AutofillHints.username], + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: AppLocalizations.of(context)!.username, + ), + textInputAction: TextInputAction.next, + onEditingComplete: () => node.nextFocus(), + onSaved: (newValue) => username = newValue, + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: TextFormField( + autocorrect: false, + obscureText: true, + keyboardType: TextInputType.visiblePassword, + autofillHints: const [AutofillHints.password], + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: AppLocalizations.of(context)!.password, + ), + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) async => await sendForm(), + onSaved: (newValue) => password = newValue, + ), + ), + ), + ], + ), + ], + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + TextButton( + onPressed: () => + Navigator.of(context).pushNamed(LogsScreen.routeName), + child: + Text(AppLocalizations.of(context)!.logs.toUpperCase()), + ), + ElevatedButton( + onPressed: + isAuthenticating ? null : () async => await sendForm(), + child: + Text(AppLocalizations.of(context)!.next.toUpperCase()), + ), + ], + ), + ), + ) + ], + ), + ); + } + + /// Function to handle logging in for Widgets, including a snackbar for errors. + Future loginHelper( + {required String username, + String? password, + required String baseUrl, + required BuildContext context}) async { + JellyfinApiHelper jellyfinApiHelper = GetIt.instance(); + + // We trim the base url in case the user accidentally added some trailing whitespce + baseUrl = baseUrl.trim(); + + jellyfinApiHelper.baseUrlTemp = Uri.parse(baseUrl); + + try { + if (password == null) { + await jellyfinApiHelper.authenticateViaName(username: username); + } else { + await jellyfinApiHelper.authenticateViaName( + username: username, + password: password, + ); + } + + if (!mounted) return; + + Navigator.of(context).pushNamed(ViewSelector.routeName); + } catch (e) { + errorSnackbar(e, context); + + // We return here to stop the function from continuing. + return; + } + } + + Future sendForm() async { + if (formKey.currentState?.validate() == true) { + formKey.currentState!.save(); + setState(() { + isAuthenticating = true; + }); + await loginHelper( + username: username!, + password: password, + baseUrl: baseUrl!, + context: context, + ); + setState(() { + isAuthenticating = false; + }); + } + } +} diff --git a/lib/components/ViewSelector/no_music_libraries_message.dart b/lib/components/ViewSelector/no_music_libraries_message.dart new file mode 100644 index 0000000..16fd85d --- /dev/null +++ b/lib/components/ViewSelector/no_music_libraries_message.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class NoMusicLibrariesMessage extends StatelessWidget { + const NoMusicLibrariesMessage({ + super.key, + this.onRefresh, + }); + + final VoidCallback? onRefresh; + + @override + Widget build(BuildContext context) { + return Center( + child: Scrollbar( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + children: [ + Text( + AppLocalizations.of(context)!.noMusicLibrariesTitle, + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + Text( + AppLocalizations.of(context)!.noMusicLibrariesBody, + textAlign: TextAlign.center, + ), + ElevatedButton( + onPressed: onRefresh, + child: Text(AppLocalizations.of(context)!.refresh)) + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/components/album_image.dart b/lib/components/album_image.dart new file mode 100644 index 0000000..35ea190 --- /dev/null +++ b/lib/components/album_image.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; +import 'package:octo_image/octo_image.dart'; + +import '../models/jellyfin_models.dart'; +import '../services/album_image_provider.dart'; + +typedef ImageProviderCallback = void Function(ImageProvider? imageProvider); + +/// This widget provides the default look for album images throughout Finamp - +/// Aspect ratio 1 with a circular border radius of 4. If you don't want these +/// customisations, use [BareAlbumImage] or get an [ImageProvider] directly +/// through [AlbumImageProvider.init]. +class AlbumImage extends StatelessWidget { + const AlbumImage({ + Key? key, + this.item, + this.imageProviderCallback, + this.itemsToPrecache, + }) : super(key: key); + + /// The item to get an image for. + final BaseItemDto? item; + + /// A callback to get the image provider once it has been fetched. + final ImageProviderCallback? imageProviderCallback; + + /// A list of items to precache + final List? itemsToPrecache; + + static final BorderRadius borderRadius = BorderRadius.circular(4); + + @override + Widget build(BuildContext context) { + if (item == null || item!.imageId == null) { + if (imageProviderCallback != null) { + imageProviderCallback!(null); + } + + return ClipRRect( + borderRadius: borderRadius, + child: const AspectRatio( + aspectRatio: 1, + child: _AlbumImageErrorPlaceholder(), + ), + ); + } + + return ClipRRect( + borderRadius: borderRadius, + child: AspectRatio( + aspectRatio: 1, + child: LayoutBuilder(builder: (context, constraints) { + // LayoutBuilder (and other pixel-related stuff in Flutter) returns logical pixels instead of physical pixels. + // While this is great for doing layout stuff, we want to get images that are the right size in pixels. + // Logical pixels aren't the same as the physical pixels on the device, they're quite a bit bigger. + // If we use logical pixels for the image request, we'll get a smaller image than we want. + // Because of this, we convert the logical pixels to physical pixels by multiplying by the device's DPI. + final MediaQueryData mediaQuery = MediaQuery.of(context); + final int physicalWidth = + (constraints.maxWidth * mediaQuery.devicePixelRatio).toInt(); + final int physicalHeight = + (constraints.maxHeight * mediaQuery.devicePixelRatio).toInt(); + + return BareAlbumImage( + item: item!, + maxWidth: physicalWidth, + maxHeight: physicalHeight, + imageProviderCallback: imageProviderCallback, + itemsToPrecache: itemsToPrecache, + ); + }), + ), + ); + } +} + +/// An [AlbumImage] without any of the padding or media size detection. +class BareAlbumImage extends StatefulWidget { + const BareAlbumImage({ + Key? key, + required this.item, + this.maxWidth, + this.maxHeight, + this.errorBuilder, + this.placeholderBuilder, + this.imageProviderCallback, + this.itemsToPrecache, + }) : super(key: key); + + final BaseItemDto item; + final int? maxWidth; + final int? maxHeight; + final WidgetBuilder? placeholderBuilder; + final OctoErrorBuilder? errorBuilder; + final ImageProviderCallback? imageProviderCallback; + + /// A list of items to precache + final List? itemsToPrecache; + + @override + State createState() => _BareAlbumImageState(); +} + +class _BareAlbumImageState extends State { + late Future _albumImageContentFuture; + late WidgetBuilder _placeholderBuilder; + late OctoErrorBuilder _errorBuilder; + + @override + void initState() { + super.initState(); + _albumImageContentFuture = AlbumImageProvider.init( + widget.item, + maxWidth: widget.maxWidth, + maxHeight: widget.maxHeight, + itemsToPrecache: widget.itemsToPrecache, + context: context, + ); + _placeholderBuilder = widget.placeholderBuilder ?? + (context) => Container( + color: Theme.of(context).cardColor, + ); + _errorBuilder = widget.errorBuilder ?? + (context, _, __) => const _AlbumImageErrorPlaceholder(); + } + + // We need to do this so that the image changes when dependencies change, such + // as when used in the player screen. + @override + void didUpdateWidget(BareAlbumImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.item.imageId != oldWidget.item.imageId || + widget.maxWidth != oldWidget.maxWidth || + widget.maxHeight != oldWidget.maxHeight || + widget.itemsToPrecache != oldWidget.itemsToPrecache) { + _albumImageContentFuture = AlbumImageProvider.init( + widget.item, + maxWidth: widget.maxWidth, + maxHeight: widget.maxHeight, + itemsToPrecache: widget.itemsToPrecache, + context: context, + ); + } + _placeholderBuilder = widget.placeholderBuilder ?? + (context) => Container( + color: Theme.of(context).cardColor, + ); + _errorBuilder = widget.errorBuilder ?? + (context, _, __) => const _AlbumImageErrorPlaceholder(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _albumImageContentFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + if (widget.imageProviderCallback != null) { + widget.imageProviderCallback!(snapshot.data!); + } + + return OctoImage( + image: snapshot.data!, + fit: BoxFit.cover, + placeholderBuilder: _placeholderBuilder, + errorBuilder: _errorBuilder, + ); + } + + if (snapshot.hasError) { + if (widget.imageProviderCallback != null) { + widget.imageProviderCallback!(null); + } + return const _AlbumImageErrorPlaceholder(); + } + + if (widget.imageProviderCallback != null) { + widget.imageProviderCallback!(null); + } + + return Builder(builder: _placeholderBuilder); + }, + ); + } +} + +class _AlbumImageErrorPlaceholder extends StatelessWidget { + const _AlbumImageErrorPlaceholder({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + color: Theme.of(context).cardColor, + child: const Icon(Icons.album), + ); + } +} diff --git a/lib/components/artists_text_spans.dart b/lib/components/artists_text_spans.dart new file mode 100644 index 0000000..f275641 --- /dev/null +++ b/lib/components/artists_text_spans.dart @@ -0,0 +1,61 @@ +import 'package:finamp/services/finamp_settings_helper.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; + +import '../../models/jellyfin_models.dart'; +import '../../services/jellyfin_api_helper.dart'; +import '../screens/artist_screen.dart'; + +List ArtistsTextSpans( + BaseItemDto item, + Color? textColour, + BuildContext context, + bool popRoutes + ) { + final jellyfinApiHelper = GetIt.instance(); + List separatedArtistTextSpans = []; + + List? artists = + item.type == "MusicAlbum" + ? item.albumArtists + : item.artistItems; + + if (artists?.isEmpty ?? true) { + separatedArtistTextSpans = [ + TextSpan( + text: "Unknown Artist", + style: TextStyle(color: textColour), + ) + ]; + } else { + artists + ?.map((e) => TextSpan( + text: e.name, + style: TextStyle(color: textColour), + recognizer: TapGestureRecognizer() + ..onTap = () { + // Offline artists aren't implemented yet so we return if offline + if (FinampSettingsHelper.finampSettings.isOffline) return; + + jellyfinApiHelper.getItemById(e.id).then((artist) => + popRoutes + ? Navigator.of(context).popAndPushNamed( + ArtistScreen.routeName, + arguments: artist) + : Navigator.of(context).pushNamed( + ArtistScreen.routeName, + arguments: artist) + ); + })) + .forEach((artistTextSpan) { + separatedArtistTextSpans.add(artistTextSpan); + separatedArtistTextSpans.add(TextSpan( + text: ", ", + style: TextStyle(color: textColour), + )); + }); + separatedArtistTextSpans.removeLast(); + } + return separatedArtistTextSpans; +} diff --git a/lib/components/blurred_image.dart b/lib/components/blurred_image.dart new file mode 100644 index 0000000..d873278 --- /dev/null +++ b/lib/components/blurred_image.dart @@ -0,0 +1,27 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +class BlurredImage extends StatelessWidget { + const BlurredImage(this.image, {Key? key}) : super(key: key); + + final ImageProvider image; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + image: DecorationImage( + image: image, + fit: BoxFit.cover, + ), + ), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), + child: Container( + decoration: BoxDecoration(color: Colors.black.withOpacity(0.5)), + ), + ), + ); + } +} diff --git a/lib/components/confirmation_prompt_dialog.dart b/lib/components/confirmation_prompt_dialog.dart new file mode 100644 index 0000000..e1b4088 --- /dev/null +++ b/lib/components/confirmation_prompt_dialog.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; + +import '../models/jellyfin_models.dart'; +import '../services/downloads_helper.dart'; +import 'error_snackbar.dart'; + +class ConfirmationPromptDialog extends AlertDialog { + const ConfirmationPromptDialog({ + Key? key, + required this.promptText, + required this.confirmButtonText, + required this.abortButtonText, + required this.onConfirmed, + required this.onAborted, + }) : super(key: key); + + final String promptText; + final String confirmButtonText; + final String abortButtonText; + final void Function()? onConfirmed; + final void Function()? onAborted; + + + @override + Widget build(BuildContext context) { + return AlertDialog( + buttonPadding: const EdgeInsets.all(0.0), + contentPadding: const EdgeInsets.all(0.0), + insetPadding: const EdgeInsets.all(32.0), + actionsPadding: const EdgeInsets.all(0.0), + actionsAlignment: MainAxisAlignment.spaceAround, + actionsOverflowAlignment: OverflowBarAlignment.center, + actionsOverflowDirection: VerticalDirection.up, + title: Text( + promptText, + style: const TextStyle(fontSize: 18), + ), + actions: [ + Container( + constraints: const BoxConstraints( + maxWidth: 150.0, + ), + child: TextButton( + child: Text(abortButtonText, + textAlign: TextAlign.center, + ), + onPressed: () { + Navigator.of(context).pop(); + onAborted?.call(); + }, + ), + ), + Container( + constraints: const BoxConstraints( + maxWidth: 150.0, + ), + child: TextButton( + child: Text(confirmButtonText, + textAlign: TextAlign.center, + softWrap: true, + ), + onPressed: () { + Navigator.of(context).pop(); // Close the dialog + onConfirmed?.call(); + }, + ), + ), + ], + ); + } +} diff --git a/lib/components/error_snackbar.dart b/lib/components/error_snackbar.dart new file mode 100644 index 0000000..c3f8f71 --- /dev/null +++ b/lib/components/error_snackbar.dart @@ -0,0 +1,45 @@ +import 'package:chopper/chopper.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +/// Snackbar with error icon for displaying errors +ScaffoldFeatureController errorSnackbar( + dynamic error, BuildContext context) { + return ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.anErrorHasOccured), + action: SnackBarAction( + label: MaterialLocalizations.of(context).moreButtonTooltip, + onPressed: () => showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.error), + content: Text(_errorText(error, context)), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(MaterialLocalizations.of(context).closeButtonLabel), + ) + ], + ), + ), + ), + ), + ); +} + +String _errorText(dynamic error, BuildContext context) { + if (error.runtimeType == Response) { + error = error as Response; + + if (error.statusCode == 401) { + return AppLocalizations.of(context)! + .responseError401(error.error.toString(), error.statusCode); + } else { + return AppLocalizations.of(context)! + .responseError(error.error.toString(), error.statusCode); + } + } else { + return error.toString(); + } +} diff --git a/lib/components/favourite_button.dart b/lib/components/favourite_button.dart new file mode 100644 index 0000000..57cf7f0 --- /dev/null +++ b/lib/components/favourite_button.dart @@ -0,0 +1,78 @@ +import 'package:finamp/components/error_snackbar.dart'; +import 'package:finamp/models/jellyfin_models.dart'; +import 'package:finamp/services/jellyfin_api_helper.dart'; +import 'package:finamp/services/music_player_background_task.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; + +class FavoriteButton extends StatefulWidget { + const FavoriteButton( + {Key? key, + required this.item, + this.onlyIfFav = false, + this.inPlayer = false}) + : super(key: key); + + final BaseItemDto? item; + final bool onlyIfFav; + final bool inPlayer; + + @override + State createState() => _FavoriteButtonState(); +} + +class _FavoriteButtonState extends State { + @override + Widget build(BuildContext context) { + final audioHandler = GetIt.instance(); + final jellyfinApiHelper = GetIt.instance(); + if (widget.item == null) { + return const SizedBox.shrink(); + } + + bool isFav = widget.item!.userData!.isFavorite; + if (widget.onlyIfFav) { + if (isFav) { + return Icon( + Icons.favorite, + color: Colors.red, + size: 24.0, + semanticLabel: AppLocalizations.of(context)!.favourite, + ); + } else { + return const SizedBox.shrink(); + } + } else { + return IconButton( + icon: Icon( + isFav ? Icons.favorite : Icons.favorite_outline, + color: isFav ? Colors.red : null, + size: 24.0, + ), + tooltip: AppLocalizations.of(context)!.favourite, + onPressed: () async { + try { + UserItemDataDto? newUserData; + if (isFav) { + newUserData = + await jellyfinApiHelper.removeFavourite(widget.item!.id); + } else { + newUserData = + await jellyfinApiHelper.addFavourite(widget.item!.id); + } + setState(() { + widget.item!.userData = newUserData; + if (widget.inPlayer) { + audioHandler.mediaItem.valueOrNull!.extras!['itemJson'] = + widget.item!.toJson(); + } + }); + } catch (e) { + errorSnackbar(e, context); + } + }, + ); + } + } +} diff --git a/lib/components/first_page_progress_indicator.dart b/lib/components/first_page_progress_indicator.dart new file mode 100644 index 0000000..b9d1a3b --- /dev/null +++ b/lib/components/first_page_progress_indicator.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +// Taken from https://github.com/EdsonBueno/infinite_scroll_pagination/blob/983763669fe7247682cd64f0f8f5d6c6f96d75d3/lib/src/ui/default_indicators/first_page_progress_indicator.dart +// Made CircularProgressIndicator adaptive + +class FirstPageProgressIndicator extends StatelessWidget { + const FirstPageProgressIndicator({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) => const Padding( + padding: EdgeInsets.all(32), + child: Center( + child: CircularProgressIndicator.adaptive(), + ), + ); +} diff --git a/lib/components/new_page_progress_indicator.dart b/lib/components/new_page_progress_indicator.dart new file mode 100644 index 0000000..cb81103 --- /dev/null +++ b/lib/components/new_page_progress_indicator.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; +// ignore: implementation_imports +import 'package:infinite_scroll_pagination/src/widgets/helpers/default_status_indicators/footer_tile.dart'; + +// Taken from https://github.com/EdsonBueno/infinite_scroll_pagination/blob/e1a826d5a06d8cfb6c2146658d59465ced4140cd/lib/src/widgets/helpers/default_status_indicators/new_page_progress_indicator.dart +// Made CircularProgressIndicator adaptive + +class NewPageProgressIndicator extends StatelessWidget { + const NewPageProgressIndicator({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) => const FooterTile( + child: CircularProgressIndicator.adaptive(), + ); +} diff --git a/lib/components/now_playing_bar.dart b/lib/components/now_playing_bar.dart new file mode 100644 index 0000000..86b5f03 --- /dev/null +++ b/lib/components/now_playing_bar.dart @@ -0,0 +1,158 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:simple_gesture_detector/simple_gesture_detector.dart'; + +import '../services/finamp_settings_helper.dart'; +import '../services/media_state_stream.dart'; +import 'album_image.dart'; +import '../models/jellyfin_models.dart'; +import '../services/process_artist.dart'; +import '../services/music_player_background_task.dart'; +import '../screens/player_screen.dart'; +import 'PlayerScreen/progress_slider.dart'; + +class NowPlayingBar extends StatelessWidget { + const NowPlayingBar({ + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + // BottomNavBar's default elevation is 8 (https://api.flutter.dev/flutter/material/BottomNavigationBar/elevation.html) + const elevation = 8.0; + final color = Theme.of(context).bottomNavigationBarTheme.backgroundColor; + + final audioHandler = GetIt.instance(); + + return SimpleGestureDetector( + onVerticalSwipe: (direction) { + if (direction == SwipeDirection.up) { + Navigator.of(context).pushNamed(PlayerScreen.routeName); + } + }, + child: StreamBuilder( + stream: mediaStateStream, + builder: (context, snapshot) { + if (snapshot.hasData) { + final playing = snapshot.data!.playbackState.playing; + + // If we have a media item and the player hasn't finished, show + // the now playing bar. + if (snapshot.data!.mediaItem != null) { + final item = BaseItemDto.fromJson( + snapshot.data!.mediaItem!.extras!["itemJson"]); + + return Material( + color: color, + elevation: elevation, + child: SafeArea( + child: SizedBox( + width: MediaQuery.of(context).size.width, + child: Stack( + children: [ + const ProgressSlider( + allowSeeking: false, + showBuffer: false, + showDuration: false, + showPlaceholder: false, + ), + Dismissible( + key: const Key("NowPlayingBar"), + direction: FinampSettingsHelper.finampSettings.disableGesture ? DismissDirection.none : DismissDirection.horizontal, + confirmDismiss: (direction) async { + if (direction == DismissDirection.endToStart) { + audioHandler.skipToNext(); + } else { + audioHandler.skipToPrevious(); + } + return false; + }, + background: const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + AspectRatio( + aspectRatio: 1, + child: FittedBox( + fit: BoxFit.fitHeight, + child: Padding( + padding: + EdgeInsets.symmetric(vertical: 8.0), + child: Icon(Icons.skip_previous), + ), + ), + ), + AspectRatio( + aspectRatio: 1, + child: FittedBox( + fit: BoxFit.fitHeight, + child: Padding( + padding: + EdgeInsets.symmetric(vertical: 8.0), + child: Icon(Icons.skip_next), + ), + ), + ), + ], + ), + ), + child: ListTile( + onTap: () => Navigator.of(context) + .pushNamed(PlayerScreen.routeName), + leading: AlbumImage(item: item), + title: Text( + snapshot.data!.mediaItem!.title, + softWrap: false, + maxLines: 1, + overflow: TextOverflow.fade, + ), + subtitle: Text( + processArtist( + snapshot.data!.mediaItem!.artist, context), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (snapshot + .data!.playbackState.processingState != + AudioProcessingState.idle) + IconButton( + // We have a key here because otherwise the + // InkWell moves over to the play/pause button + key: const ValueKey("StopButton"), + icon: const Icon(Icons.stop), + onPressed: () => audioHandler.stop(), + ), + playing + ? IconButton( + icon: const Icon(Icons.pause), + onPressed: () => audioHandler.pause(), + ) + : IconButton( + icon: const Icon(Icons.play_arrow), + onPressed: () => audioHandler.play(), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } else { + return const SizedBox.shrink(); + } + } else { + return const SizedBox.shrink(); + } + }, + ), + ); + } +} \ No newline at end of file diff --git a/lib/components/print_duration.dart b/lib/components/print_duration.dart new file mode 100644 index 0000000..7140c5c --- /dev/null +++ b/lib/components/print_duration.dart @@ -0,0 +1,17 @@ +/// Flutter doesn't have a nice way of formatting durations for some reason so I stole this code from StackOverflow +String printDuration(Duration? duration) { + if (duration == null) { + return "00:00"; + } + + String twoDigits(int n) => n.toString().padLeft(2, "0"); + String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60)); + String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60)); + + if (duration.inHours >= 1) { + String twoDigitHours = twoDigits(duration.inHours); + return "$twoDigitHours:$twoDigitMinutes:$twoDigitSeconds"; + } + + return "$twoDigitMinutes:$twoDigitSeconds"; +} diff --git a/lib/components/view_icon.dart b/lib/components/view_icon.dart new file mode 100644 index 0000000..fe75056 --- /dev/null +++ b/lib/components/view_icon.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; + +class ViewIcon extends StatelessWidget { + const ViewIcon({ + Key? key, + required this.collectionType, + this.color, + }) : super(key: key); + + final String? collectionType; + final Color? color; + + @override + Widget build(BuildContext context) { + switch (collectionType) { + case "movies": + return Icon( + Icons.movie, + color: color, + ); + case "tvshows": + return Icon( + Icons.tv, + color: color, + ); + case "music": + return Icon( + Icons.music_note, + color: color, + ); + case "games": + return Icon( + Icons.games, + color: color, + ); + case "books": + return Icon( + Icons.book, + color: color, + ); + case "musicvideos": + return Icon( + Icons.music_video, + color: color, + ); + case "homevideos": + return Icon( + Icons.videocam, + color: color, + ); + case "livetv": + return Icon( + Icons.live_tv, + color: color, + ); + case "channels": + return Icon( + Icons.settings_remote, + color: color, + ); + case "playlists": + return Icon( + Icons.queue_music, + color: color, + ); + default: + return Icon( + Icons.warning, + color: color, + ); + } + } +} diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb new file mode 100644 index 0000000..e3eeb95 --- /dev/null +++ b/lib/l10n/app_ar.arb @@ -0,0 +1,469 @@ +{ + "serverUrl": "عنوان الخادم", + "@serverUrl": {}, + "emptyServerUrl": "لا يمكن عنوان الخادم يكون فارغ", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "urlStartWithHttps": "العنوان يجب ان يبدأ مع \"//:http\" أو \"//:https\"", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "logs": "السجلات", + "@logs": {}, + "username": "اسم المستعمل", + "@username": {}, + "unknownName": "إسم غير معروف", + "@unknownName": {}, + "couldNotFindLibraries": "لا يوجد أي مكتب.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "selectMusicLibraries": "إختار مكاتب الأغاني", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "songs": "الأغاني", + "@songs": {}, + "next": "التالي", + "@next": {}, + "albums": "ألبومات", + "@albums": {}, + "artists": "الفنانين", + "@artists": {}, + "genres": "التصنيفات", + "@genres": {}, + "playlists": "قوائم الأغاني", + "@playlists": {}, + "startMix": "خلط فوري", + "@startMix": {}, + "startMixNoSongsArtist": "إكبس طويل على البوم لتضيف أو تزيل هم من بناء الخلطة قبل ان تبدأ الخلطة", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "favourites": "مفضلات", + "@favourites": {}, + "finamp": "Finamp", + "@finamp": {}, + "clear": "إمسح", + "@clear": {}, + "shuffleAll": "استماع عشوائي للكل", + "@shuffleAll": {}, + "startMixNoSongsAlbum": "إكبس طويل على البوم لتضيف أو تزيل ها من بناء الخلطة قبل ان تبدأ الخلطة", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "music": "موسيقى", + "@music": {}, + "downloads": "التنزيلات", + "@downloads": {}, + "settings": "الإعدادات", + "@settings": {}, + "album": "ألبوم", + "@album": {}, + "sortBy": "فرز", + "@sortBy": {}, + "sortOrder": "ترتيب الفرز", + "@sortOrder": {}, + "offlineMode": "موضع غير الإتصال", + "@offlineMode": {}, + "albumArtist": "فنان الألبوم", + "@albumArtist": {}, + "artist": "الفنان", + "@artist": {}, + "communityRating": "تقييم الجمهور", + "@communityRating": {}, + "criticRating": "تقييم النقاد", + "@criticRating": {}, + "datePlayed": "تاريخ استماع", + "@datePlayed": {}, + "playCount": "عدد الاستمعات", + "@playCount": {}, + "premiereDate": "تاريخ عرض الأول", + "@premiereDate": {}, + "budget": "ميزنية", + "@budget": {}, + "random": "عشوائي", + "@random": {}, + "name": "إسم", + "@name": {}, + "downloadMissingImages": "نزل صور المفقودة", + "@downloadMissingImages": {}, + "revenue": "إيرادات", + "@revenue": {}, + "runtime": "وقت تشغيل", + "@runtime": {}, + "downloadCount": "{count,plural, =1{{count} تنزيل} other{{count} تنزيلات}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrors": "اخطأ التنزيل", + "@downloadErrors": {}, + "downloadedImagesCount": "{count,plural,=1{{count} صورة} other{{count} صور}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsCount": "{count,plural,=1{{count} بند} other{{count} بنود}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlComplete": "{count} متكمل", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlFailed": "{count} فشل", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlEnqueued": "{count} ينتضر النزيل", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "اخطأ التنزيل", + "@downloadErrorsTitle": {}, + "dlRunning": "{count} يتم تنزيل الآن", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "noErrors": "لا يوجد اخطأ!", + "@noErrors": {}, + "error": "خطأ", + "@error": {}, + "discNumber": "القرس {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "playButtonLabel": "استمع", + "@playButtonLabel": {}, + "shuffleButtonLabel": "استمع عشوائياً", + "@shuffleButtonLabel": {}, + "songCount": "{count,plural,=1{{count} أغتية} other{{count} أغاني}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "updateButtonLabel": "تحديث", + "@updateButtonLabel": {}, + "editPlaylistNameTooltip": "غير اسم قائمة الأغاني", + "@editPlaylistNameTooltip": {}, + "favourite": "المفضل", + "@favourite": {}, + "playlistNameUpdated": "تم تحديث إسم قائمة الأغاني.", + "@playlistNameUpdated": {}, + "required": "ملزوم", + "@required": {}, + "addButtonLabel": "إضافة", + "@addButtonLabel": {}, + "shareLogs": "شارك السجلات", + "@shareLogs": {}, + "addDownloads": "إضافة تنزيلات", + "@addDownloads": {}, + "downloadsAdded": "تم اضافة التنزيلات", + "@downloadsAdded": {}, + "location": "موقع", + "@location": {}, + "logsCopied": "تم نسخ السجلات.", + "@logsCopied": {}, + "transcoding": "تحويل", + "@transcoding": {}, + "message": "رسالة", + "@message": {}, + "stackTrace": "إشارة تراكمية", + "@stackTrace": {}, + "areYouSure": "هل انت متأكد؟", + "@areYouSure": {}, + "audioService": "خدمة الصوت", + "@audioService": {}, + "enableTranscoding": "تمكين التحويل", + "@enableTranscoding": {}, + "notAvailableInOfflineMode": "عير متوفر في وضع غير الإتصال", + "@notAvailableInOfflineMode": {}, + "jellyfinUsesAACForTranscoding": "\"جلي فين\" يستعمل مشفر للتحويل", + "@jellyfinUsesAACForTranscoding": {}, + "layoutAndTheme": "تخطيط و ظاهرة", + "@layoutAndTheme": {}, + "logOut": "تسجيل الخروج", + "@logOut": {}, + "bitrate": "معدل البتات", + "@bitrate": {}, + "customLocation": "موقع مخصص", + "@customLocation": {}, + "appDirectory": "مجلّد التطبيق", + "@appDirectory": {}, + "unknownError": "خطأ غير معروف", + "@unknownError": {}, + "directoryMustBeEmpty": "المجلد يجب ان يكون فارغ", + "@directoryMustBeEmpty": {}, + "enterLowPriorityStateOnPause": "تشغيل وضع عندما توقف استماع مؤقتاً", + "@enterLowPriorityStateOnPause": {}, + "shuffleAllSongCountSubtitle": "عدد الأغاني التي تحمل عندما تستعمل كبسة \"استماع عشوائ لكل الأغاني\".", + "@shuffleAllSongCountSubtitle": {}, + "shuffleAllSongCount": "استماع عشوائي لعدد الأغاني", + "@shuffleAllSongCount": {}, + "grid": "شبكة", + "@grid": {}, + "portrait": "عمودي", + "@portrait": {}, + "landscape": "اقفي", + "@landscape": {}, + "list": "قائمة", + "@list": {}, + "viewType": "نوع العرض", + "@viewType": {}, + "viewTypeSubtitle": "نوع عرض لصفة الموسيقى", + "@viewTypeSubtitle": {}, + "showTextOnGridView": "أظهر كلمات على عرض الشبك", + "@showTextOnGridView": {}, + "gridCrossAxisCount": "عدد محور معامد في عرض {value}", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "showTextOnGridViewSubtitle": "سواء ان تظهر كلمات [الإسم، الفنان، أو الباقي] على صفحة الموسيقى التي تستعمل عرض الشبكي.", + "@showTextOnGridViewSubtitle": {}, + "showCoverAsPlayerBackgroundSubtitle": "سواء ان تستعمل مخلاف البوم غائم على مشغل الموسيقى.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "hideSongArtistsIfSameAsAlbumArtists": "إخفاء أسماء فنانين اﻷغنية إذا هن نفس أسماء فنانين الأابوم", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "theme": "الألوان التطبيق", + "@theme": {}, + "light": "ابيض", + "@light": {}, + "cancelSleepTimer": "هل تريد ان تلغي مؤقت النوم؟", + "@cancelSleepTimer": {}, + "dark": "غامق", + "@dark": {}, + "tabs": "التبويبات", + "@tabs": {}, + "noButtonLabel": "ﻷ", + "@noButtonLabel": {}, + "yesButtonLabel": "نعم", + "@yesButtonLabel": {}, + "addToPlaylistTooltip": "إضافة إلى قائمة اغاني", + "@addToPlaylistTooltip": {}, + "addToPlaylistTitle": "إضافة إلى قائمة اغاني", + "@addToPlaylistTitle": {}, + "createButtonLabel": "إنشاء", + "@createButtonLabel": {}, + "setSleepTimer": "حدد مؤقت النوم", + "@setSleepTimer": {}, + "sleepTimerTooltip": "مؤقت النوم", + "@sleepTimerTooltip": {}, + "newPlaylist": "قائمة اغاني جديد", + "@newPlaylist": {}, + "invalidNumber": "رقم خاطىء", + "@invalidNumber": {}, + "unknownArtist": "فنان غير معروف", + "@unknownArtist": {}, + "playlistCreated": "تم إنشاء قائمة الأغاني.", + "@playlistCreated": {}, + "streaming": "بث", + "@streaming": {}, + "noAlbum": "بدون البوم", + "@noAlbum": {}, + "noItem": "لا يوجد بند", + "@noItem": {}, + "transcode": "تحويل", + "@transcode": {}, + "goToAlbum": "إذهب إلى الألبوم", + "@goToAlbum": {}, + "addToQueue": "إضافة إلى قائمة الانتظار", + "@addToQueue": {}, + "downloaded": "نزلت", + "@downloaded": {}, + "queue": "قائمة الإنتظار", + "@queue": {}, + "direct": "مباشر", + "@direct": {}, + "instantMix": "خلط فوري", + "@instantMix": {}, + "statusError": "خطأ حالة", + "@statusError": {}, + "addFavourite": "إضافة إلى المفضلات", + "@addFavourite": {}, + "addedToQueue": "تم إضافة إالى قائمة الانتظار", + "@addedToQueue": {}, + "queueReplaced": "تم تبديل قائمة الإنتظار.", + "@queueReplaced": {}, + "startingInstantMix": "ابتداء خلط فوري.", + "@startingInstantMix": {}, + "addDownloadLocation": "إضافة مكان التنزيل", + "@addDownloadLocation": {}, + "errorScreenError": "حدث خطأ اثناء حصول على قائمة الأخطأ! في هذه المرحلة ربما يجب عليك ان تفتح تعليقة على <‎GitHub> و تمسح تخزين التطبيق", + "@errorScreenError": {}, + "downloadLocations": "مواقع التنزيل", + "@downloadLocations": {}, + "editPlaylistNameTitle": "غير اسم قائمة الأغاني", + "@editPlaylistNameTitle": {}, + "enableTranscodingSubtitle": "إذا ممكن, بث الموسيقى سايتحول من الخادم", + "@enableTranscodingSubtitle": {}, + "responseError": "{error} رمز الخطأ {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "responseError401": "{error} رمز الخطأ {statusCode}. ربما يعني ان استخدمت الإسم أو الكامة السرية الخاطئة, أو عميلك لا يعد موثوق.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "bitrateSubtitle": "معدل بتات اعلى يعطي جودة صوت أحسن واكن يستعمل اكثر بيانات.", + "@bitrateSubtitle": {}, + "dateAdded": "تاريخ الإضافة", + "@dateAdded": {}, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "productionYear": "سنة الإنتاج", + "@productionYear": {}, + "removeFavourite": "إزالة من المفضلات", + "@removeFavourite": {}, + "replaceQueue": "تبديل قائمة الإنتظار", + "@replaceQueue": {}, + "system": "نظام", + "@system": {}, + "startupError": "‐شيء خطأ حصل اثناء تفتيح التطبيك! الخطأ هو:{error}\n\nاقتح تعليقةعلى في الموقع مع لقتة شاشة من هذا الصفحة. إذا هذا الخطأ يستمر, إمسح تخزين التطبيق.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "noArtist": "بدون فنان", + "@noArtist": {}, + "password": "كلمة السرية", + "@password": {}, + "selectDirectory": "إختار المجلد", + "@selectDirectory": {}, + "downloadedSongsWillNotBeDeleted": "لن تزيل لأغني التي تم تنزيله", + "@downloadedSongsWillNotBeDeleted": {}, + "downloadsDeleted": "تم إزالة التنزيلات", + "@downloadsDeleted": {}, + "failedToGetSongFromDownloadId": "فشل ان يجد الاغنية من رقم التنزيل", + "@failedToGetSongFromDownloadId": {}, + "internalExternalIpExplanation": "إذا تريد ان توصل خدامة \"جلي فين\" من بعيد, يجيب ان تستعمل آي بي(IP) الخارجي.\n\nإذا خادمك يستعمل منافذ الويب(80 أو 443), لا يجب ان تحدد منفذ. هذه محتمل جدا إذا خادمك بستعمل\"Reverse proxy\".", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "pathReturnSlashErrorMessage": "مسارات التي ترجع \"/\" لا يمكن ان تستعمل", + "@pathReturnSlashErrorMessage": {}, + "showCoverAsPlayerBackground": "أظهر مخلاف البوم غائم على مشغل الموسيقى", + "@showCoverAsPlayerBackground": {}, + "urlTrailingSlash": "العنوان لا يمكن ان ينهي مع \"/\"", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "enterLowPriorityStateOnPauseSubtitle": "إذا ممكن، يمكن ان تمسح الإعلام عنمدا توقف مؤقتاً للاستماع. و يمكن \"اندرويد\" ان يقتل الخدمة.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "سواء ان تخفي فنانين الأغنية من صفحة الألبوم إذا يختلف من فنانين الألبوم.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "applicationLegalese": "مرخص مع رخصة موزيلا العمومية . الشريفة توجد على:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "gridCrossAxisCountSubtitle": "كم مربعات تستعمل لكل صف عندما الشاشة تكون {value}.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "downloadedMissingImages": "{count,plural, =0{لا يوجد صور مفقودة} =1{تم تنزيل {count} صورة مفقودة} other{ تم تنزيل {count} صور المفقودة}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "anErrorHasOccured": "حدث خطأ.", + "@anErrorHasOccured": {}, + "customLocationsBuggy": "المواقع المخصصة عرضة للأخطأ لأن يوجد مشاكل مع الأذونات. أنا أحوال إصلاحه, و لكن لا أنصح ان تستغدمه.", + "@customLocationsBuggy": {}, + "addToMix": "إضافة إالى الخلطة", + "@addToMix": {}, + "removeFromMix": "إزالة من الخلطة", + "@removeFromMix": {}, + "minutes": "دقائق", + "@minutes": {}, + "removeFromPlaylistTooltip": "إزالة من قائمة الأغاني", + "@removeFromPlaylistTooltip": {}, + "removeFromPlaylistTitle": "إزالة من قائمة الأغاني", + "@removeFromPlaylistTitle": {}, + "removedFromPlaylist": "تمت الإزالة من قائمة الأغاني.", + "@removedFromPlaylist": {}, + "language": "اللغة", + "@language": {} +} diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb new file mode 100644 index 0000000..cdcbe2f --- /dev/null +++ b/lib/l10n/app_bg.arb @@ -0,0 +1,469 @@ +{ + "serverUrl": "URL на сървъра", + "@serverUrl": {}, + "emptyServerUrl": "URL адресът на сървъра не може да бъде празен", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "urlStartWithHttps": "URL адресът трябва да започва с http:// или https://", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "urlTrailingSlash": "URL адресът не трябва да включва наклонена черта", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "username": "Потребителско име", + "@username": {}, + "password": "Парола", + "@password": {}, + "logs": "Логове", + "@logs": {}, + "next": "Следващ", + "@next": {}, + "couldNotFindLibraries": "Не могат да бъдат открити библиотеки.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "unknownName": "Неизвестно Име", + "@unknownName": {}, + "songs": "Песни", + "@songs": {}, + "albums": "Албуми", + "@albums": {}, + "artists": "Изпълнители", + "@artists": {}, + "genres": "Жанрове", + "@genres": {}, + "playlists": "Плейлисти", + "@playlists": {}, + "startMix": "Стартирайте разбъркано възпроизвеждане", + "@startMix": {}, + "music": "Музика", + "@music": {}, + "clear": "Изчисти", + "@clear": {}, + "favourites": "Любими", + "@favourites": {}, + "shuffleAll": "Възпроизвеждане в случаен ред", + "@shuffleAll": {}, + "finamp": "Finamp", + "@finamp": {}, + "downloads": "Изтегляния", + "@downloads": {}, + "settings": "Настройки", + "@settings": {}, + "offlineMode": "Офлайн режим", + "@offlineMode": {}, + "sortOrder": "Подредба", + "@sortOrder": {}, + "sortBy": "Подредба по", + "@sortBy": {}, + "album": "Албум", + "@album": {}, + "artist": "Изпълнител", + "@artist": {}, + "albumArtist": "Изпълнител на албума", + "@albumArtist": {}, + "budget": "Бюджет", + "@budget": {}, + "communityRating": "Обществен рейтинг", + "@communityRating": {}, + "criticRating": "Рейтинг на професионалната общност", + "@criticRating": {}, + "dateAdded": "Дата на добавяне", + "@dateAdded": {}, + "datePlayed": "Дата на последно изпълнение", + "@datePlayed": {}, + "playCount": "Брой изпълнения", + "@playCount": {}, + "premiereDate": "Премиерна дата", + "@premiereDate": {}, + "name": "Име", + "@name": {}, + "random": "Произволно", + "@random": {}, + "revenue": "Приходи", + "@revenue": {}, + "runtime": "Продължителност", + "@runtime": {}, + "downloadErrors": "Грешки при изтеглянето", + "@downloadErrors": {}, + "dlComplete": "{count} завършено", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlFailed": "{count} грешка", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlEnqueued": "{count} опашка", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "{count} изпълнява се", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "Грешки при изтегляне", + "@downloadErrorsTitle": {}, + "noErrors": "Без грешки!", + "@noErrors": {}, + "downloadedImagesCount": "{count,plural,=1{{count} изображение} other{{count} изображения}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "failedToGetSongFromDownloadId": "Неуспех при получаване на песен, посредством ID на изтегляне", + "@failedToGetSongFromDownloadId": {}, + "error": "Грешка", + "@error": {}, + "discNumber": "Диск {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "playButtonLabel": "ПУСНИ", + "@playButtonLabel": {}, + "shuffleButtonLabel": "РАЗБЪРКАНО", + "@shuffleButtonLabel": {}, + "editPlaylistNameTooltip": "Редактирай името на списъка за изпълнение", + "@editPlaylistNameTooltip": {}, + "required": "Задължително", + "@required": {}, + "updateButtonLabel": "АКТУАЛИЗАЦИЯ", + "@updateButtonLabel": {}, + "playlistNameUpdated": "Името на списъка за изпълнение е обновено.", + "@playlistNameUpdated": {}, + "favourite": "Любими", + "@favourite": {}, + "addDownloads": "Добави за изтегляне", + "@addDownloads": {}, + "location": "Местоположение", + "@location": {}, + "shareLogs": "Споделете логовете", + "@shareLogs": {}, + "logsCopied": "Логове копирани.", + "@logsCopied": {}, + "message": "Съобщение", + "@message": {}, + "stackTrace": "Проследяване на стека", + "@stackTrace": {}, + "transcoding": "Транскодиране", + "@transcoding": {}, + "downloadLocations": "Местоположение на изтеглянията", + "@downloadLocations": {}, + "audioService": "Аудио услуга", + "@audioService": {}, + "layoutAndTheme": "Оформление и тема", + "@layoutAndTheme": {}, + "notAvailableInOfflineMode": "Недостъпно в офлайн режим", + "@notAvailableInOfflineMode": {}, + "logOut": "Излезте от профила си", + "@logOut": {}, + "areYouSure": "Сигурни ли сте?", + "@areYouSure": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin използва AAC за транскодиране", + "@jellyfinUsesAACForTranscoding": {}, + "enableTranscoding": "Активиране на транскодирането", + "@enableTranscoding": {}, + "enableTranscodingSubtitle": "Транскодирането на музикалните потоци се осъществява от страна на сървъра.", + "@enableTranscodingSubtitle": {}, + "bitrate": "Битрейт", + "@bitrate": {}, + "bitrateSubtitle": "По-високият битрейт гарантира висококачествено аудио за сметка на завишен мрежови трафик.", + "@bitrateSubtitle": {}, + "customLocation": "Персонализирано местоположение", + "@customLocation": {}, + "appDirectory": "Директория на приложението", + "@appDirectory": {}, + "addDownloadLocation": "Добавете местоположение за изтегляне", + "@addDownloadLocation": {}, + "selectDirectory": "Изберете директория", + "@selectDirectory": {}, + "unknownError": "Неизвестна грешка", + "@unknownError": {}, + "pathReturnSlashErrorMessage": "Невъзможно е указването на път, използващ \"/\"", + "@pathReturnSlashErrorMessage": {}, + "directoryMustBeEmpty": "Директорията трябва да е празна", + "@directoryMustBeEmpty": {}, + "enterLowPriorityStateOnPause": "При пауза, използвай режим на нисък приоритет", + "@enterLowPriorityStateOnPause": {}, + "shuffleAllSongCount": "Разбъркайте всички песни", + "@shuffleAllSongCount": {}, + "shuffleAllSongCountSubtitle": "Брой песни за зареждане при използване на бутона за разбъркване на всички песни.", + "@shuffleAllSongCountSubtitle": {}, + "viewType": "Вид преглед", + "@viewType": {}, + "viewTypeSubtitle": "Вид преглед на плейъра", + "@viewTypeSubtitle": {}, + "list": "Списък", + "@list": {}, + "grid": "Решетка", + "@grid": {}, + "portrait": "Портретен", + "@portrait": {}, + "landscape": "Пейзажен", + "@landscape": {}, + "gridCrossAxisCountSubtitle": "Броят на показваните елементи на ред, когато {value}.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "showTextOnGridView": "Показване на текст в решетъчен изглед", + "@showTextOnGridView": {}, + "showCoverAsPlayerBackground": "Показване на замъглена обложка като фон на плейъра", + "@showCoverAsPlayerBackground": {}, + "hideSongArtistsIfSameAsAlbumArtists": "Скриване изпълнителя на песента, ако е същият като изпълнителя на албума", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Дали да се покаже изпълнителят на песента на екрана, ако не се различава от изпълнителя на албума.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "theme": "Тема", + "@theme": {}, + "system": "Система", + "@system": {}, + "light": "Светла", + "@light": {}, + "dark": "Тъмна", + "@dark": {}, + "tabs": "Раздели", + "@tabs": {}, + "cancelSleepTimer": "Отмяна на таймера?", + "@cancelSleepTimer": {}, + "yesButtonLabel": "Да", + "@yesButtonLabel": {}, + "noButtonLabel": "Не", + "@noButtonLabel": {}, + "setSleepTimer": "Задаване на таймер", + "@setSleepTimer": {}, + "minutes": "Минути", + "@minutes": {}, + "invalidNumber": "Невалидно число", + "@invalidNumber": {}, + "sleepTimerTooltip": "Таймер", + "@sleepTimerTooltip": {}, + "addToPlaylistTooltip": "Добавяне към списъка за изпълнение", + "@addToPlaylistTooltip": {}, + "addToPlaylistTitle": "Добавяне към списъка за изпълнение", + "@addToPlaylistTitle": {}, + "newPlaylist": "Нов списък за изпълнение", + "@newPlaylist": {}, + "createButtonLabel": "Създаване", + "@createButtonLabel": {}, + "playlistCreated": "Списъкът за изпълнение е създаден.", + "@playlistCreated": {}, + "noItem": "Липсващ файл", + "@noItem": {}, + "noAlbum": "Липсващ албум", + "@noAlbum": {}, + "unknownArtist": "Неизвестен изпълнител", + "@unknownArtist": {}, + "streaming": "Стрийминг", + "@streaming": {}, + "downloaded": "Изтегляне", + "@downloaded": {}, + "transcode": "Транскодиране", + "@transcode": {}, + "statusError": "Грешка в състоянието", + "@statusError": {}, + "queue": "Опашка", + "@queue": {}, + "addToQueue": "Добавяне към опашката", + "@addToQueue": {}, + "replaceQueue": "Заменете опашката", + "@replaceQueue": {}, + "goToAlbum": "Отидете в албума", + "@goToAlbum": {}, + "removeFavourite": "Премахнете любими", + "@removeFavourite": {}, + "addFavourite": "Добавете в любими", + "@addFavourite": {}, + "queueReplaced": "Опашката е заменена.", + "@queueReplaced": {}, + "startingInstantMix": "Стартиране на незабавен микс.", + "@startingInstantMix": {}, + "anErrorHasOccured": "Появи се грешка.", + "@anErrorHasOccured": {}, + "responseError": "{error} Код на грешката {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "removeFromMix": "Премахни от микса", + "@removeFromMix": {}, + "addToMix": "Добави към микса", + "@addToMix": {}, + "startupError": "Нещо се обърка по време на стартиране на приложението. Грешката беше:{error}\n\nДокладвайте проблема, с екранна снимка, на github.com/UnicornsOnLSD/finamp, Ако проблемът продължава, изчистете кеша или преинсталирайте приложението.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "internalExternalIpExplanation": "В случай, че искате да имате достъп до вашия Jellyfin сървър дистанционно, трябва да използвате външния си IP адрес.\n\nВ случай, че сървърът ви работи на HTTP порт (80/443), не е нужно да посочвате такъв. Например ако използвате обратно прокси.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "selectMusicLibraries": "Изберете музикални библиотеки", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "startMixNoSongsArtist": "Натиснете и задръжте върху изпълнител, за да го добавите или премахнете, от разбъркано възпроизвеждане", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "startMixNoSongsAlbum": "Натиснете и задръжте върху албум, за да го добавите или премахнете от разбъркано възпроизвеждане", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "productionYear": "Година на издаване", + "@productionYear": {}, + "downloadMissingImages": "Изтегляне на липсващите изображения", + "@downloadMissingImages": {}, + "downloadedMissingImages": "{count,plural, =0{Не са открити липсващи изображения} =1{Изтегляне {count} липсващи изображения} other{Изтегляне {count} липсващи изображения}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadCount": "{count,plural, =1{{count} изтегляне} other{{count} изтегляне}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "errorScreenError": "Възникна грешка при получаване на списъка с грешки! Преинсталирайте приложението и съобщете за проблема на GitHub", + "@errorScreenError": {}, + "editPlaylistNameTitle": "Редактирай името на списъка за изпълнение", + "@editPlaylistNameTitle": {}, + "downloadsDeleted": "Изтеглянията са изтрити.", + "@downloadsDeleted": {}, + "downloadsAdded": "Добавено в изтегляния.", + "@downloadsAdded": {}, + "addButtonLabel": "ДОБАВИ", + "@addButtonLabel": {}, + "applicationLegalese": "Лиценз Mozilla Public License 2.0. Програмният код е достъпен на:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "downloadedSongsWillNotBeDeleted": "Изтеглените песни няма да бъдат изтрити", + "@downloadedSongsWillNotBeDeleted": {}, + "songCount": "{count,plural,=1{{count} Песен} other{{count} Песни}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "enterLowPriorityStateOnPauseSubtitle": "Известията да бъдат отхвърлени при пауза. Също така позволи на Android да прекрати услугата при пауза.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "customLocationsBuggy": "Персонализираните местоположения са изключително нестабилни, поради проблеми с правата. Обмислям варианти са справяне с проблема, но за сега непрепоръчвам тяхното използване.", + "@customLocationsBuggy": {}, + "gridCrossAxisCount": "{value} Брой елементи в решетката", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "showTextOnGridViewSubtitle": "Дали да се показва или не текст (името на песента, изпълнителя и т.н.), в решетъчен режим.", + "@showTextOnGridViewSubtitle": {}, + "showCoverAsPlayerBackgroundSubtitle": "Дали да се показва или не, замъглена обложка на албума като фон на плейъра.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "noArtist": "Липсващ изпълнител", + "@noArtist": {}, + "direct": "Непосредствен", + "@direct": {}, + "instantMix": "Незабавен микс", + "@instantMix": {}, + "addedToQueue": "Добавено към опашката.", + "@addedToQueue": {}, + "responseError401": "{error} Код на грешката {statusCode}. Това вероятно означава, че сте използвали грешно потребителско име/парола, или вашият клиент вече не е удостоверен.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "redownloadedItems": "{count,plural, =0{Не е необходимо повторно изтегляне.} =1{Повторно изтегляне {count} елемент} other{Повторно изтегляне {count} елементи}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsCount": "{count,plural,=1{{count} елемент} other{{count} елементи}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + } +} diff --git a/lib/l10n/app_ca.arb b/lib/l10n/app_ca.arb new file mode 100644 index 0000000..a976687 --- /dev/null +++ b/lib/l10n/app_ca.arb @@ -0,0 +1,80 @@ +{ + "serverUrl": "URL del servidor", + "@serverUrl": {}, + "downloaded": "DESCARREGAT", + "@downloaded": {}, + "removeFavourite": "Suprimeix el favorit", + "@removeFavourite": {}, + "addFavourite": "Afegeix favorit", + "@addFavourite": {}, + "addedToQueue": "S'ha afegit a la cua.", + "@addedToQueue": {}, + "addToMix": "Afegir a la barreja", + "@addToMix": {}, + "statusError": "ERROR D'ESTAT", + "@statusError": {}, + "startupError": "S'ha produït un error durant l'inici de l'aplicació. L'error va ser: {error}\n\nCreeu un problema a github.com/UnicornsOnLSD/finamp amb una captura de pantalla d'aquesta pàgina. Si aquest problema persisteix, podeu esborrar les dades de l'aplicació per restablir-la.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "queue": "Cua", + "@queue": {}, + "replaceQueue": "Substitueix la cua", + "@replaceQueue": {}, + "internalExternalIpExplanation": "Si voleu poder accedir al vostre servidor Jellyfin de forma remota, heu d'utilitzar la vostra IP externa.\n\nSi el vostre servidor està en un port HTTP (80/443), no cal que especifiqueu cap port. És probable que aquest sigui el cas si el vostre servidor està darrere d'un servidor intermediari invers.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "transcode": "TRANSCODIFICACIÓ", + "@transcode": {}, + "direct": "DIRECTE", + "@direct": {}, + "instantMix": "Barreja instantània", + "@instantMix": {}, + "addToQueue": "Afegir a la cua", + "@addToQueue": {}, + "goToAlbum": "Vés a l'àlbum", + "@goToAlbum": {}, + "removedFromPlaylist": "S'ha suprimit de la llista de reproducció.", + "@removedFromPlaylist": {}, + "queueReplaced": "S'ha substituït la cua.", + "@queueReplaced": {}, + "startingInstantMix": "Inici de la barreja instantània.", + "@startingInstantMix": {}, + "anErrorHasOccured": "S'ha produït un error.", + "@anErrorHasOccured": {}, + "responseError": "{error} Codi d'estat {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "responseError401": "{error} Codi d'estat {statusCode}. Això probablement vol dir que heu utilitzat un nom d'usuari/contrasenya incorrecte o que el vostre client ha tancat la sessió.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "removeFromMix": "Eliminar de la barreja", + "@removeFromMix": {} +} diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb new file mode 100644 index 0000000..b87a04a --- /dev/null +++ b/lib/l10n/app_cs.arb @@ -0,0 +1,590 @@ +{ + "albums": "Alba", + "@albums": {}, + "artists": "Umělci", + "@artists": {}, + "clear": "Vymazat", + "@clear": {}, + "downloads": "Stažené", + "@downloads": {}, + "favourites": "Oblíbené", + "@favourites": {}, + "finamp": "Finamp", + "@finamp": {}, + "album": "Album", + "@album": {}, + "albumArtist": "Umělec alba", + "@albumArtist": {}, + "errorScreenError": "Při načítání seznamu chyb došlo k chybě! Vytvořte prosím problém na GitHubu a vymažte data aplikace", + "@errorScreenError": {}, + "artist": "Umělec", + "@artist": {}, + "dlComplete": "{count} dokončeno", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlEnqueued": "{count} ve frontě", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlFailed": "{count} selhalo", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dateAdded": "Datum přidání", + "@dateAdded": {}, + "datePlayed": "Datum přehrání", + "@datePlayed": {}, + "discNumber": "Disk {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "downloadMissingImages": "Stáhnout chybějící obrázky", + "@downloadMissingImages": {}, + "failedToGetSongFromDownloadId": "Error nelze sehnat skladbu s ID pro stažení", + "@failedToGetSongFromDownloadId": {}, + "editPlaylistNameTooltip": "Upravit název seznamu skladeb", + "@editPlaylistNameTooltip": {}, + "editPlaylistNameTitle": "Upravit název seznamu skladeb", + "@editPlaylistNameTitle": {}, + "addButtonLabel": "PŘIDAT", + "@addButtonLabel": {}, + "addDownloadLocation": "Přidat umístění stažených", + "@addDownloadLocation": {}, + "bitrateSubtitle": "Vyšší datový tok poskytuje vyšší kvalitu zvuku, ale zvýší využití internetu.", + "@bitrateSubtitle": {}, + "appDirectory": "Adresář aplikací", + "@appDirectory": {}, + "areYouSure": "Opravdu?", + "@areYouSure": {}, + "bitrate": "Datový tok", + "@bitrate": {}, + "directoryMustBeEmpty": "Adresář musí být prázdný", + "@directoryMustBeEmpty": {}, + "downloadedSongsWillNotBeDeleted": "Stažené skladby nebudou odstraněny", + "@downloadedSongsWillNotBeDeleted": {}, + "favourite": "Oblíbené", + "@favourite": {}, + "gridCrossAxisCountSubtitle": "Počet položek mřížky na řádek {value}.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "shuffleAllSongCountSubtitle": "Počet skladeb, které se mají načíst při použití tlačítka náhodného přehrávání všech skladeb.", + "@shuffleAllSongCountSubtitle": {}, + "cancelSleepTimer": "Zrušit časovač spánku?", + "@cancelSleepTimer": {}, + "grid": "Mřížka", + "@grid": {}, + "hideSongArtistsIfSameAsAlbumArtists": "Nezobrazovat umělce, pokud je totožný s umělcem alba", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "addToPlaylistTooltip": "Přidat do seznamu skladeb", + "@addToPlaylistTooltip": {}, + "addToPlaylistTitle": "Přidat do seznamu skladeb", + "@addToPlaylistTitle": {}, + "responseError": "{error} Stavový kód {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "addedToQueue": "Přidáno do fronty.", + "@addedToQueue": {}, + "addFavourite": "Přidat do oblíbených", + "@addFavourite": {}, + "addToMix": "Přidat do mixu", + "@addToMix": {}, + "addToQueue": "Přidat do fronty", + "@addToQueue": {}, + "anErrorHasOccured": "Vyskytla se chyba.", + "@anErrorHasOccured": {}, + "downloaded": "STAŽENO", + "@downloaded": {}, + "goToAlbum": "Přejít na album", + "@goToAlbum": {}, + "couldNotFindLibraries": "Nebyly nalezeny žádné knihovny.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "enableTranscoding": "Zapnout překódování", + "@enableTranscoding": {}, + "genres": "Žánry", + "@genres": {}, + "logs": "Protokoly", + "@logs": {}, + "password": "Heslo", + "@password": {}, + "next": "Další", + "@next": {}, + "username": "Uživatelské jméno", + "@username": {}, + "minutes": "Minuty", + "@minutes": {}, + "disableGesture": "Zakázat gesta", + "@disableGesture": {}, + "disableGestureSubtitle": "Zda zakázat gesta.", + "@disableGestureSubtitle": {}, + "removeFromPlaylistTooltip": "Odebrat ze seznamu skladeb", + "@removeFromPlaylistTooltip": {}, + "replaceQueue": "Nahradit frontu", + "@replaceQueue": {}, + "serverUrl": "Adresa URL serveru", + "@serverUrl": {}, + "emptyServerUrl": "Adresa URL serveru nemůže být prázdná", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "selectMusicLibraries": "Vyberte hudební knihovny", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "settings": "Nastavení", + "@settings": {}, + "offlineMode": "Režim offline", + "@offlineMode": {}, + "sortOrder": "Pořadí řazení", + "@sortOrder": {}, + "internalExternalIpExplanation": "Pokud budete chtít vzdáleně přistupovat k vašemu serveru Jellyfin, budete muset použít vaši externí IP.\n\nPokud je váš server na portu HTTP (80/443), nemusíte specifikovat port. Toto bývá obvyklé, když máte server za reverzní proxy.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "startMixNoSongsArtist": "Dlouze podržte prst na umělci pro jeho přidání nebo odebrání z tvorby mixu před jeho spuštěním", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "startMixNoSongsAlbum": "Dlouze podržte prst na albu pro jeho přidání nebo odebrání z tvorby mixu před jeho spuštěním", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "budget": "Rozpočet", + "@budget": {}, + "communityRating": "Komunitní hodnocení", + "@communityRating": {}, + "playCount": "Počet přehrání", + "@playCount": {}, + "premiereDate": "Datum premiéry", + "@premiereDate": {}, + "revenue": "Výnos", + "@revenue": {}, + "runtime": "Doba běhu", + "@runtime": {}, + "shuffleButtonLabel": "NÁHODNĚ", + "@shuffleButtonLabel": {}, + "removedFromPlaylist": "Odebráno ze seznamu skladeb.", + "@removedFromPlaylist": {}, + "startingInstantMix": "Spouštění okamžitého mixu.", + "@startingInstantMix": {}, + "bufferDuration": "Trvání vyrovnávací paměti", + "@bufferDuration": {}, + "showUncensoredLogMessage": "Tento protokol zobrazuje vaše přihlašovací informace. Chcete jej zobrazit?", + "@showUncensoredLogMessage": {}, + "resetTabs": "Obnovit karty", + "@resetTabs": {}, + "message": "Zpráva", + "@message": {}, + "stackTrace": "Trasování", + "@stackTrace": {}, + "applicationLegalese": "Licence Mozilla Public License 2.0. Zdrojový kód je dostupný na stránce:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "notAvailableInOfflineMode": "Není dostupné v režimu offline", + "@notAvailableInOfflineMode": {}, + "layoutAndTheme": "Rozložení a motiv", + "@layoutAndTheme": {}, + "logOut": "Odhlásit se", + "@logOut": {}, + "customLocation": "Vlastní umístění", + "@customLocation": {}, + "unknownError": "Neznámá chyba", + "@unknownError": {}, + "pathReturnSlashErrorMessage": "Cesty, které vracejí „/“, nelze použít", + "@pathReturnSlashErrorMessage": {}, + "customLocationsBuggy": "Vlastní umístění bývají kvůli problémům s oprávněními extrémně chybové. Snažíme se tento problém opravit, do té doby ale nedoporučujeme jejich používání.", + "@customLocationsBuggy": {}, + "enterLowPriorityStateOnPause": "Po pozastavení vstoupit do stavu nízké priority", + "@enterLowPriorityStateOnPause": {}, + "shuffleAllSongCount": "Počet skladeb pro náhodné přehrávání všeho", + "@shuffleAllSongCount": {}, + "portrait": "Na výšku", + "@portrait": {}, + "viewType": "Typ zobrazení", + "@viewType": {}, + "viewTypeSubtitle": "Typ zobrazení pro hudební obrazovku", + "@viewTypeSubtitle": {}, + "list": "Seznam", + "@list": {}, + "showCoverAsPlayerBackground": "Zobrazit rozmazaný obal jako pozadí přehrávače", + "@showCoverAsPlayerBackground": {}, + "showCoverAsPlayerBackgroundSubtitle": "Zda použít rozmazaný obal alba jako pozadí na obrazovce přehrávače.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "system": "Systémový", + "@system": {}, + "light": "Světlý", + "@light": {}, + "theme": "Motiv", + "@theme": {}, + "dark": "Tmavý", + "@dark": {}, + "tabs": "Karty", + "@tabs": {}, + "setSleepTimer": "Nastavit časovač spánku", + "@setSleepTimer": {}, + "invalidNumber": "Neplatné číslo", + "@invalidNumber": {}, + "createButtonLabel": "VYTVOŘIT", + "@createButtonLabel": {}, + "playlistCreated": "Seznam skladeb vytvořen.", + "@playlistCreated": {}, + "noAlbum": "Žádné album", + "@noAlbum": {}, + "noItem": "Žádná položka", + "@noItem": {}, + "noArtist": "Žádný umělec", + "@noArtist": {}, + "unknownArtist": "Neznámý umělec", + "@unknownArtist": {}, + "streaming": "STREAMOVÁNÍ", + "@streaming": {}, + "transcode": "PŘEKÓDOVÁNÍ", + "@transcode": {}, + "direct": "PŘÍMÝ", + "@direct": {}, + "statusError": "CHYBA STAVU", + "@statusError": {}, + "queue": "Fronta", + "@queue": {}, + "instantMix": "Okamžitý mix", + "@instantMix": {}, + "removeFavourite": "Odebrat z oblíbených", + "@removeFavourite": {}, + "queueReplaced": "Fronta nahrazena.", + "@queueReplaced": {}, + "removeFromMix": "Odebrat z mixu", + "@removeFromMix": {}, + "noMusicLibrariesTitle": "Žádné hudební knihovny", + "@noMusicLibrariesTitle": { + "description": "Title for message that shows on the views screen when no music libraries could be found." + }, + "refresh": "OBNOVIT", + "@refresh": {}, + "language": "Jazyk", + "@language": {}, + "syncDownloadedPlaylists": "Synchronizovat stažené seznamy skladeb", + "@syncDownloadedPlaylists": {}, + "downloadedMissingImages": "{count,plural, =0{Nenalezeny žádné chybějící obrázky} =1{Stažen {count} chybějící obrázek} few{Staženy {count} chybějící obrázky} other{Staženo {count} chybějících obrázků}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrors": "Chyby stahování", + "@downloadErrors": {}, + "downloadCount": "{count,plural, =1{{count} stahování} other{{count} stahování}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsCount": "{count,plural,=1{{count} položka} few{{count} položky} other{{count} položek}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedImagesCount": "{count,plural,=1{{count} obrázek} few{{count} obrázky} other{{count} obrázků}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "{count} běží", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "Chyby stahování", + "@downloadErrorsTitle": {}, + "noErrors": "Žádné chyby!", + "@noErrors": {}, + "deleteDownloadsPrompt": "Opravdu chcete odstranit {itemType, select, album{album} playlist{seznam skladeb} artist{umělce} genre{žánr} track{skladbu} other{}} '{itemName}' z tohoto zařízení?", + "@deleteDownloadsPrompt": { + "placeholders": { + "itemName": { + "type": "String", + "example": "Abandon Ship" + }, + "itemType": { + "type": "String", + "example": "album" + } + }, + "description": "Confirmation prompt shown before deleting downloaded media from the local device, destructive action, doesn't affect the media on the server." + }, + "playButtonLabel": "PŘEHRÁT", + "@playButtonLabel": {}, + "songCount": "{count,plural,=1{{count} skladba} few{{count} skladby} other{{count} skladeb}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "required": "Vyžadováno", + "@required": {}, + "updateButtonLabel": "UPRAVIT", + "@updateButtonLabel": {}, + "playlistNameUpdated": "Název seznamu skladeb upraven.", + "@playlistNameUpdated": {}, + "downloadsDeleted": "Stahování odstraněna.", + "@downloadsDeleted": {}, + "addDownloads": "Přidat stahování", + "@addDownloads": {}, + "location": "Umístění", + "@location": {}, + "downloadsAdded": "Stahování přidána.", + "@downloadsAdded": {}, + "shareLogs": "Sdílet protokoly", + "@shareLogs": {}, + "logsCopied": "Protokoly zkopírovány.", + "@logsCopied": {}, + "transcoding": "Překódování", + "@transcoding": {}, + "downloadLocations": "Umístění stažených", + "@downloadLocations": {}, + "audioService": "Služba zvuku", + "@audioService": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin používá pro překódování formát AAC", + "@jellyfinUsesAACForTranscoding": {}, + "enableTranscodingSubtitle": "Překóduje hudební streamy na straně serveru.", + "@enableTranscodingSubtitle": {}, + "selectDirectory": "Vybrat adresář", + "@selectDirectory": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Zda zobrazit umělce skladby na obrazovce alba, pokud se neliší od umělců alba.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "showFastScroller": "Zobrazit rychlý posuvník", + "@showFastScroller": {}, + "landscape": "Na šířku", + "@landscape": {}, + "gridCrossAxisCount": "Počet položek mřížky {value}", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "showTextOnGridView": "Zobrazit text v zobrazení v mřížce", + "@showTextOnGridView": {}, + "showTextOnGridViewSubtitle": "Zda zobrazit text (název, umělce atd.) v mřížkovém zobrazení hudební obrazovky.", + "@showTextOnGridViewSubtitle": {}, + "yesButtonLabel": "ANO", + "@yesButtonLabel": {}, + "noButtonLabel": "NE", + "@noButtonLabel": {}, + "sleepTimerTooltip": "Časovač spánku", + "@sleepTimerTooltip": {}, + "removeFromPlaylistTitle": "Odebrat ze seznamu skladeb", + "@removeFromPlaylistTitle": {}, + "newPlaylist": "Nový seznam skladeb", + "@newPlaylist": {}, + "responseError401": "{error} Stavový kód {statusCode}. Toto nejspíše znamená, že jste použili nesprávné uživatelské jméno / heslo, nebo váš klient již není přihlášen.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "redownloadedItems": "{count,plural, =0{Nejsou potřeba žádná opětovná stažení.} =1{Znovu stažena{count} položka} few{Znovu staženy{count} položky} other{Znovu staženo {count} položek}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "bufferDurationSubtitle": "Kolik sekund dopředu by měl přehrávač uložit do vyrovnávací paměti. Vyžaduje restart aplikace.", + "@bufferDurationSubtitle": {}, + "startupError": "Při spouštění aplikace se něco pokazilo. Chyba: {error}\n\nNahlaste prosím problém na stránce github.com/UnicornsOnLSD/finamp společně se snímkem obrazovky této stránky. Pokud tento problém přetrvává, můžete vymazat data aplikace pro její obnovení.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "urlStartWithHttps": "Adresa URL musí začínat s http:// nebo https://", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "urlTrailingSlash": "Adresa URL nesmí obsahovat koncové lomítko", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "startMix": "Spustit mix", + "@startMix": {}, + "music": "Hudba", + "@music": {}, + "shuffleAll": "Vše náhodně", + "@shuffleAll": {}, + "unknownName": "Neznámý název", + "@unknownName": {}, + "playlists": "Seznamy skladeb", + "@playlists": {}, + "sortBy": "Řadit podle", + "@sortBy": {}, + "songs": "Skladby", + "@songs": {}, + "criticRating": "Hodnocení kritiků", + "@criticRating": {}, + "productionYear": "Rok produkce", + "@productionYear": {}, + "name": "Název", + "@name": {}, + "random": "Náhodně", + "@random": {}, + "deleteDownloadsAbortButtonText": "Zrušit", + "@deleteDownloadsAbortButtonText": {}, + "deleteDownloadsConfirmButtonText": "Odstranit", + "@deleteDownloadsConfirmButtonText": { + "description": "Shown in the confirmation dialog for deleting downloaded media from the local device." + }, + "error": "Chyba", + "@error": {}, + "enterLowPriorityStateOnPauseSubtitle": "Umožní odstranění notifikace při pozastavení. Také umožní systému ukončit službu.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "playNext": "Přehrát další", + "@playNext": { + "description": "Popup menu item title for inserting an item into the play queue after the currently-playing item." + }, + "insertedIntoQueue": "Vloženo do fronty.", + "@insertedIntoQueue": { + "description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end." + }, + "confirm": "Potvrdit", + "@confirm": {}, + "noMusicLibrariesBody": "Finamp nenalezl žádné hudební knihovny. Ujistěte se prosím, že váš server Jellyfin obsahuje alespoň jednu knihovnu s typem obsahu nastaveným na „Hudba“.", + "@noMusicLibrariesBody": {}, + "swipeInsertQueueNextSubtitle": "Zapněte pro vložení skladby jako další položku do fronty po posunutí prstem na skladbě v seznamu skladeb, místo jejího přiřazení na konec.", + "@swipeInsertQueueNextSubtitle": {}, + "interactions": "Interakce", + "@interactions": {}, + "swipeInsertQueueNext": "Přehrát posunutou skladbu jako další", + "@swipeInsertQueueNext": {}, + "redesignBeta": "Vyzkoušejte beta verzi", + "@redesignBeta": {}, + "playbackOrderShuffledTooltip": "Náhodně. Klepnutím přepnete.", + "@playbackOrderShuffledTooltip": {}, + "playbackOrderLinearTooltip": "Přehrávání v pořadí. Klepnutím přepnete.", + "@playbackOrderLinearTooltip": {}, + "loopModeAllTooltip": "Opakování všeho. Klepnutím přepnete.", + "@loopModeAllTooltip": {}, + "loopModeOneTooltip": "Opakování jedné. Klepnutím přepnete.", + "@loopModeOneTooltip": {}, + "loopModeNoneTooltip": "Bez opakování. Klepnutím přepnete.", + "@loopModeNoneTooltip": {}, + "skipToPrevious": "Přeskočit na předchozí skladbu", + "@skipToPrevious": {}, + "skipToNext": "Přeskočit na další skladbu", + "@skipToNext": {}, + "togglePlayback": "Přepnout přehrávání", + "@togglePlayback": {}, + "downloadArtist": "Stáhnout všechna alba umělce {artist}", + "@downloadArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "shuffleArtist": "Přehrát náhodně všechna alba umělce {artist}", + "@shuffleArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "playArtist": "Přehrát všechna alba umělce {artist}", + "@playArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "deleteFromDevice": "Smazat ze zařízení", + "@deleteFromDevice": {}, + "download": "Stáhnout", + "@download": {}, + "sync": "Synchronizovat se serverem", + "@sync": {}, + "about": "O aplikaci Finamp", + "@about": {} +} diff --git a/lib/l10n/app_da.arb b/lib/l10n/app_da.arb new file mode 100644 index 0000000..d11f76a --- /dev/null +++ b/lib/l10n/app_da.arb @@ -0,0 +1,485 @@ +{ + "serverUrl": "Server URL", + "@serverUrl": {}, + "emptyServerUrl": "Server URL kan ikke være tom", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "urlStartWithHttps": "URL skal starte med http:// eller https://", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "urlTrailingSlash": "URL må ikke indeholde en efterfølgnede stråstreg", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "username": "Brugernavn", + "@username": {}, + "password": "Adgangskode", + "@password": {}, + "logs": "Log", + "@logs": {}, + "next": "Næste", + "@next": {}, + "selectMusicLibraries": "Vælg musik biblioteker", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "couldNotFindLibraries": "Kunne ikke finde nogle biblioteker.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "songs": "Sange", + "@songs": {}, + "artists": "Kunstnere", + "@artists": {}, + "genres": "Genre", + "@genres": {}, + "startMix": "Start blanding", + "@startMix": {}, + "music": "Musik", + "@music": {}, + "clear": "Ryd", + "@clear": {}, + "favourites": "Favoritter", + "@favourites": {}, + "shuffleAll": "Bland alle", + "@shuffleAll": {}, + "finamp": "Finamp", + "@finamp": {}, + "downloads": "Overførelser", + "@downloads": {}, + "settings": "Indstillinger", + "@settings": {}, + "offlineMode": "Offline tilstand", + "@offlineMode": {}, + "sortOrder": "Sorter efter", + "@sortOrder": {}, + "sortBy": "Sortér efter", + "@sortBy": {}, + "album": "Album", + "@album": {}, + "albumArtist": "Album kunstner", + "@albumArtist": {}, + "artist": "Kunstner", + "@artist": {}, + "criticRating": "Kritiker bedømmelse", + "@criticRating": {}, + "communityRating": "Fællesskab bedømmelse", + "@communityRating": {}, + "dateAdded": "Dato tilføjet", + "@dateAdded": {}, + "datePlayed": "Afspillet den", + "@datePlayed": {}, + "playCount": "Antal afspilninger", + "@playCount": {}, + "premiereDate": "Udgivelsesdato", + "@premiereDate": {}, + "productionYear": "Produktionsår", + "@productionYear": {}, + "name": "Navn", + "@name": {}, + "random": "Tilfældig", + "@random": {}, + "revenue": "Omsætning", + "@revenue": {}, + "budget": "Budget", + "@budget": {}, + "downloadMissingImages": "Overførelse mangler billeder", + "@downloadMissingImages": {}, + "downloadErrors": "Overførelsesfejl", + "@downloadErrors": {}, + "downloadedItemsCount": "{count,plural,=1{{count} emne} other{{count} emner}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedImagesCount": "{count,plural,=1{{count} billede} other{{count} billeder}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlComplete": "{count} færdige", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlFailed": "{count} fejlede", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlEnqueued": "{count} i kø", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "noErrors": "Ingen fejl!", + "@noErrors": {}, + "failedToGetSongFromDownloadId": "Fejlede under hentning af sang fra overførelse ID", + "@failedToGetSongFromDownloadId": {}, + "error": "Fejl", + "@error": {}, + "discNumber": "Disk {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "playButtonLabel": "AFSPIL", + "@playButtonLabel": {}, + "editPlaylistNameTooltip": "Rediger afspilningslistens navn", + "@editPlaylistNameTooltip": {}, + "editPlaylistNameTitle": "Rediger afspilningslistens navn", + "@editPlaylistNameTitle": {}, + "required": "Påkrævet", + "@required": {}, + "updateButtonLabel": "OPDATÉR", + "@updateButtonLabel": {}, + "playlistNameUpdated": "Afspilningslistens navn er opdateret.", + "@playlistNameUpdated": {}, + "favourite": "Favorit", + "@favourite": {}, + "addDownloads": "Tilføj overførelser", + "@addDownloads": {}, + "location": "Placering", + "@location": {}, + "downloadsAdded": "Overførelse tilføjet.", + "@downloadsAdded": {}, + "shareLogs": "Del log", + "@shareLogs": {}, + "stackTrace": "Bunke spor", + "@stackTrace": {}, + "transcoding": "Omkoder", + "@transcoding": {}, + "downloadLocations": "Overførelse placering", + "@downloadLocations": {}, + "audioService": "Lyd tjeneste", + "@audioService": {}, + "layoutAndTheme": "Udseende & tema", + "@layoutAndTheme": {}, + "notAvailableInOfflineMode": "Ikke tilgængelig i offline tilstand", + "@notAvailableInOfflineMode": {}, + "logOut": "Log af", + "@logOut": {}, + "downloadedSongsWillNotBeDeleted": "Overførte sange vil ikke blive slettet", + "@downloadedSongsWillNotBeDeleted": {}, + "areYouSure": "Er du sikker?", + "@areYouSure": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin bruger AAC ved omkodning", + "@jellyfinUsesAACForTranscoding": {}, + "enableTranscoding": "Aktivér omkodning", + "@enableTranscoding": {}, + "enableTranscodingSubtitle": "Omkoder musik under afspilning på serveren.", + "@enableTranscodingSubtitle": {}, + "bitrateSubtitle": "En højere bithastighed giver højere lydkvalitet men bruger mere båndbredde.", + "@bitrateSubtitle": {}, + "customLocation": "Vælg placering", + "@customLocation": {}, + "appDirectory": "App katalog", + "@appDirectory": {}, + "addDownloadLocation": "Tilføj overførelse placering", + "@addDownloadLocation": {}, + "selectDirectory": "Vælg katalog", + "@selectDirectory": {}, + "unknownError": "Ukendt fejl", + "@unknownError": {}, + "directoryMustBeEmpty": "Katalog skal være tomt", + "@directoryMustBeEmpty": {}, + "enterLowPriorityStateOnPause": "Gå i lav prioritet tilstand når musik afspilning er på pause", + "@enterLowPriorityStateOnPause": {}, + "enterLowPriorityStateOnPauseSubtitle": "Tillader notifikationer bliver strøget væk, når afspilning er sat på pause. Dette tillader også Android, at afbryde servicen når den er på pause.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "shuffleAllSongCount": "Bland alle sange antal", + "@shuffleAllSongCount": {}, + "viewType": "Visning tilstand", + "@viewType": {}, + "viewTypeSubtitle": "Visningstype for musik skærmen", + "@viewTypeSubtitle": {}, + "list": "Liste", + "@list": {}, + "grid": "Gitter", + "@grid": {}, + "portrait": "Portræt", + "@portrait": {}, + "landscape": "Landskab", + "@landscape": {}, + "gridCrossAxisCountSubtitle": "Antal gitrebrikker som bruges per række {value}.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "showTextOnGridView": "Vis tekst i gitter visning", + "@showTextOnGridView": {}, + "showTextOnGridViewSubtitle": "Vælg om tekst skal vises (titel, kunstner osv.) på gitteret musik skærm.", + "@showTextOnGridViewSubtitle": {}, + "showCoverAsPlayerBackground": "Vis sløret cover som afspiller baggrund", + "@showCoverAsPlayerBackground": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Vælg om sangens kunstner skal vises på albummet, hvis den ikke er forskellig fra albummets kunstner.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "disableGesture": "Deaktivér bevægelser", + "@disableGesture": {}, + "disableGestureSubtitle": "Vælg om bevægelser skal deaktiveres.", + "@disableGestureSubtitle": {}, + "theme": "Tema", + "@theme": {}, + "light": "Lys", + "@light": {}, + "dark": "Mørk", + "@dark": {}, + "tabs": "Faner", + "@tabs": {}, + "cancelSleepTimer": "Vil du annullere sove timeren?", + "@cancelSleepTimer": {}, + "yesButtonLabel": "JA", + "@yesButtonLabel": {}, + "minutes": "Minutter", + "@minutes": {}, + "sleepTimerTooltip": "Sove timer", + "@sleepTimerTooltip": {}, + "addToPlaylistTooltip": "Tilføj til afspilningsliste", + "@addToPlaylistTooltip": {}, + "addToPlaylistTitle": "Tilføj til afspilningsliste", + "@addToPlaylistTitle": {}, + "removeFromPlaylistTooltip": "Fjern fra afspilningsliste", + "@removeFromPlaylistTooltip": {}, + "removeFromPlaylistTitle": "Fjern fra afspilningsliste", + "@removeFromPlaylistTitle": {}, + "newPlaylist": "Ny afspilningsliste", + "@newPlaylist": {}, + "createButtonLabel": "OPRET", + "@createButtonLabel": {}, + "playlistCreated": "Afspilningsliste er oprettet.", + "@playlistCreated": {}, + "noAlbum": "Intet album", + "@noAlbum": {}, + "noItem": "Intet objekt", + "@noItem": {}, + "invalidNumber": "Ugyldig nummer", + "@invalidNumber": {}, + "streaming": "STREAMER", + "@streaming": {}, + "downloaded": "OVERFØRELSER", + "@downloaded": {}, + "transcode": "OMKOD", + "@transcode": {}, + "direct": "DIREKTE", + "@direct": {}, + "statusError": "Status fejl", + "@statusError": {}, + "queue": "Kø", + "@queue": {}, + "addToQueue": "Tilføj til kø", + "@addToQueue": {}, + "replaceQueue": "Udskift køen", + "@replaceQueue": {}, + "instantMix": "Hurtig blanding", + "@instantMix": {}, + "removeFavourite": "Fjern favorit", + "@removeFavourite": {}, + "addFavourite": "Tilføj favorit", + "@addFavourite": {}, + "addedToQueue": "Tilføjet til kø.", + "@addedToQueue": {}, + "queueReplaced": "Køen er udskiftet.", + "@queueReplaced": {}, + "removedFromPlaylist": "Fjernet fra afspilningslisten.", + "@removedFromPlaylist": {}, + "startingInstantMix": "Starter hurtig blanding.", + "@startingInstantMix": {}, + "downloadCount": "{count,plural, =1{{count} overført} other{{count} overførelser}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "removeFromMix": "Fjern fra blanding", + "@removeFromMix": {}, + "addToMix": "Tilføj til blanding", + "@addToMix": {}, + "redownloadedItems": "{count,plural, =0{Ingen genoverførsel er nødvendig.} =1{Genoverførte {count} objekt} other{Genoverfører {count} objekter}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "bufferDuration": "Buffer varighed", + "@bufferDuration": {}, + "bufferDurationSubtitle": "Hvor meget afspilleren skal forudindlæse i sekunder. En genstart er nødvendig.", + "@bufferDurationSubtitle": {}, + "startupError": "Noget gik galt under app opstarten. Fejlen er {error}\n\nVenligst opret en sag på github.com/UnicornsOnLSD/finamp med et skærmbillede af denne side. Hvis dette problem forbliver, så kan du fjerne dine app data for at nulstille appen.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "internalExternalIpExplanation": "Hvis du vil være i stand til at tilgå din Jellyfin server udefra, skal du bruge din eksterne IP.\n\nHvis din server er på HTTP porten (80/443), behøver du ikke angive en port. Dette er som regel tilfældet, hvis din server er bag en reverse proxy.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "unknownName": "Ukendt navn", + "@unknownName": {}, + "albums": "Albummer", + "@albums": {}, + "playlists": "Afspilningslister", + "@playlists": {}, + "startMixNoSongsArtist": "Tryk (lang tid) på en kunstner for at tilføje eller fjerne den fra blandingen før du starter en blanding", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "startMixNoSongsAlbum": "Tryk (lang tid) på et album for at tilføje eller fjerne det fra blandingen for du starter en blanding", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "runtime": "Spilletid", + "@runtime": {}, + "downloadedMissingImages": "{count,plural, =0{Ingen manglende billeder blev fundet} =1{Overførte {count} manglende billeder} other{Overførte {count} manglende billeder}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "{count} er igang", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "Overførelsesfejl", + "@downloadErrorsTitle": {}, + "shuffleButtonLabel": "BLAND", + "@shuffleButtonLabel": {}, + "errorScreenError": "Der opstod en fejl under hentningen af listen med fejl! På dette tidspunkt, bedes du oprette en fejl på Github og slette appens data", + "@errorScreenError": {}, + "songCount": "{count,plural,=1{{count} Sang} other{{count} Sange}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadsDeleted": "Overførelser slettet.", + "@downloadsDeleted": {}, + "addButtonLabel": "TILFØJ", + "@addButtonLabel": {}, + "bitrate": "Bithastighed", + "@bitrate": {}, + "logsCopied": "Log kopieret.", + "@logsCopied": {}, + "message": "Besked", + "@message": {}, + "applicationLegalese": "Licenseret med Mozilla Public License 2.0. Kildeteksten er tilgængelig på:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "pathReturnSlashErrorMessage": "Stier, der returnerer \"/\", kan ikke anvendes", + "@pathReturnSlashErrorMessage": {}, + "customLocationsBuggy": "Valgfrie placeringer er ekstremt fejlramte grundet manglende rettigheder. Jeg prøver at finde måder at løse dette, men for nu anbefaler jeg ikke, at de anvendes.", + "@customLocationsBuggy": {}, + "noArtist": "Ingen kunstner", + "@noArtist": {}, + "unknownArtist": "Ukendt kunstner", + "@unknownArtist": {}, + "shuffleAllSongCountSubtitle": "Mængde af sange der skal indlæses, når bland alle sange knappen bruges.", + "@shuffleAllSongCountSubtitle": {}, + "hideSongArtistsIfSameAsAlbumArtists": "Skjul sang kunstner, hvis det er den samme som albummets kunstner", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "gridCrossAxisCount": "{value} Gitter tværakse antal", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "setSleepTimer": "Indtil sove timer", + "@setSleepTimer": {}, + "showCoverAsPlayerBackgroundSubtitle": "Vælg om der skal bruges sløret omslagskunst som vises som baggrund på afspiller skærmen.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "system": "System", + "@system": {}, + "noButtonLabel": "NEJ", + "@noButtonLabel": {}, + "goToAlbum": "Gå til album", + "@goToAlbum": {}, + "anErrorHasOccured": "Der opstod en fejl.", + "@anErrorHasOccured": {}, + "responseError401": "{error} Status kode {statusCode}. Dette betyder, at du har anvendt et forkert brugernavn/adgangskode eller at din klient ikke længere er logget på.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "responseError": "{error} Status kode {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "language": "Sprog", + "@language": {} +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb new file mode 100644 index 0000000..46d861b --- /dev/null +++ b/lib/l10n/app_de.arb @@ -0,0 +1,590 @@ +{ + "music": "Musik", + "@music": {}, + "areYouSure": "Bist du sicher?", + "@areYouSure": {}, + "albums": "Alben", + "@albums": {}, + "artists": "Künstler", + "@artists": {}, + "album": "Album", + "@album": {}, + "albumArtist": "Albumkünstler", + "@albumArtist": {}, + "artist": "Künstler", + "@artist": {}, + "bitrate": "Bitrate", + "@bitrate": {}, + "emptyServerUrl": "Server-URL darf nicht leer sein", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "urlStartWithHttps": "URL muss mit http:// oder https:// beginnen", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "urlTrailingSlash": "URL darf keinen Schrägstrich am Ende enthalten", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "couldNotFindLibraries": "Konnte keine Bibliotheken finden.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "startMix": "Starte Mix", + "@startMix": {}, + "startMixNoSongsAlbum": "Vor dem Starten eines Mixes lange auf ein Album gedrückt halten, um es zum Mixzusammensteller hinzuzufügen oder zu entfernen", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "clear": "Entfernen", + "@clear": {}, + "internalExternalIpExplanation": "Wenn du deinen Jellyfin Server aus der Ferne erreichen möchtest, benötigst du eine externe IP.\n\nWenn dein Server einen HTTP Port (80/443) benutzt, musst du keinen Port festlegen. Dies ist vermutlich der Fall wenn dein Server sich hinter einer Reverse Proxy befindet.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "next": "Weiter", + "@next": {}, + "logs": "Protokolle", + "@logs": {}, + "startMixNoSongsArtist": "Vor dem Starten eines Mixes lange auf einen Künstler gedrückt halten, um ihn zum Mixzusammensteller hinzuzufügen oder zu entfernen", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "unknownName": "Unbekannter Name", + "@unknownName": {}, + "username": "Benutzername", + "@username": {}, + "genres": "Genres", + "@genres": {}, + "playlists": "Playlists", + "@playlists": {}, + "songs": "Lieder", + "@songs": {}, + "favourites": "Favoriten", + "@favourites": {}, + "finamp": "Finamp", + "@finamp": {}, + "settings": "Einstellungen", + "@settings": {}, + "communityRating": "Community-Bewertung", + "@communityRating": {}, + "offlineMode": "Offline-Modus", + "@offlineMode": {}, + "sortBy": "Sortieren nach", + "@sortBy": {}, + "sortOrder": "Sortierreihenfolge", + "@sortOrder": {}, + "productionYear": "Produktionsjahr", + "@productionYear": {}, + "random": "Zufällig", + "@random": {}, + "error": "Fehler", + "@error": {}, + "required": "Erforderlich", + "@required": {}, + "message": "Nachricht", + "@message": {}, + "logOut": "Abmelden", + "@logOut": {}, + "notAvailableInOfflineMode": "Im Offline-Modus nicht verfügbar", + "@notAvailableInOfflineMode": {}, + "directoryMustBeEmpty": "Verzeichnis muss leer sein", + "@directoryMustBeEmpty": {}, + "enableTranscoding": "Transkodierung aktivieren", + "@enableTranscoding": {}, + "grid": "Raster", + "@grid": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin verwendet AAC zur Transkodierung", + "@jellyfinUsesAACForTranscoding": {}, + "list": "Liste", + "@list": {}, + "unknownError": "Unbekannter Fehler", + "@unknownError": {}, + "invalidNumber": "Ungültige Zahl", + "@invalidNumber": {}, + "noButtonLabel": "NEIN", + "@noButtonLabel": {}, + "yesButtonLabel": "JA", + "@yesButtonLabel": {}, + "createButtonLabel": "ERSTELLEN", + "@createButtonLabel": {}, + "direct": "DIREKT", + "@direct": {}, + "newPlaylist": "Neue Wiedergabeliste", + "@newPlaylist": {}, + "playlistCreated": "Wiedergabeliste erstellt.", + "@playlistCreated": {}, + "removeFavourite": "Favorit entfernen", + "@removeFavourite": {}, + "unknownArtist": "Unbekannter Künstler", + "@unknownArtist": {}, + "downloadedSongsWillNotBeDeleted": "Heruntergeladene Lieder werden nicht gelöscht", + "@downloadedSongsWillNotBeDeleted": {}, + "favourite": "Favorit", + "@favourite": {}, + "name": "Name", + "@name": {}, + "password": "Passwort", + "@password": {}, + "showTextOnGridView": "Text in Rasteransicht anzeigen", + "@showTextOnGridView": {}, + "noErrors": "Keine Fehler!", + "@noErrors": {}, + "selectMusicLibraries": "Musikbibliotheken auswählen", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "startupError": "Während dem Starten der App ist etwas schief gelaufen. Der Fehler war: {error}\n\nBitte öffne ein Issue auf github.com/UnicornsOnLSD/finamp mit einem Bildschirmfoto von dieser Seite. Wenn dieses Problem weiterhin besteht, kannst du deine App-Daten löschen, um die App zurückzusetzen.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "serverUrl": "Server-URL", + "@serverUrl": {}, + "shuffleAll": "Alle mischen", + "@shuffleAll": {}, + "downloads": "Downloads", + "@downloads": {}, + "budget": "Budget", + "@budget": {}, + "criticRating": "Kritikerbewertung", + "@criticRating": {}, + "dateAdded": "Datum hinzugefügt", + "@dateAdded": {}, + "datePlayed": "Datum gespielt", + "@datePlayed": {}, + "playCount": "Anzahl abgespielt", + "@playCount": {}, + "revenue": "Einspielergebnis", + "@revenue": {}, + "runtime": "Dauer", + "@runtime": {}, + "downloadMissingImages": "Fehlende Bilder herunterladen", + "@downloadMissingImages": {}, + "downloadErrors": "Download-Fehler", + "@downloadErrors": {}, + "downloadCount": "{count,plural, =1{{count} Download} other{{count} Downloads}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlFailed": "{count} fehlgeschlagen", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlComplete": "{count} fertiggestellt", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlEnqueued": "{count} in der Warteschlange", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "{count} laufend", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "Download-Fehler", + "@downloadErrorsTitle": {}, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "failedToGetSongFromDownloadId": "Song konnte nicht über die Download-ID abgerufen werden", + "@failedToGetSongFromDownloadId": {}, + "playButtonLabel": "ABSPIELEN", + "@playButtonLabel": {}, + "shuffleButtonLabel": "MISCHEN", + "@shuffleButtonLabel": {}, + "editPlaylistNameTitle": "Wiedergabeliste-Name editieren", + "@editPlaylistNameTitle": {}, + "updateButtonLabel": "AKTUALISIERUNG", + "@updateButtonLabel": {}, + "downloadsDeleted": "Downloads gelöscht.", + "@downloadsDeleted": {}, + "addDownloads": "Downloads hinzufügen", + "@addDownloads": {}, + "location": "Ort", + "@location": {}, + "shareLogs": "Protokolle teilen", + "@shareLogs": {}, + "stackTrace": "Stacktrace", + "@stackTrace": {}, + "downloadLocations": "Download Orte", + "@downloadLocations": {}, + "audioService": "Audio Service", + "@audioService": {}, + "layoutAndTheme": "Aufbau & Thema", + "@layoutAndTheme": {}, + "transcoding": "Transkodierung", + "@transcoding": {}, + "enableTranscodingSubtitle": "Transkodiert Musikstreams serverseitig.", + "@enableTranscodingSubtitle": {}, + "bitrateSubtitle": "Eine höhere Bitrate verbessert die Audioqualität auf Kosten der benötigten Bandbreite.", + "@bitrateSubtitle": {}, + "customLocation": "Benutzerdefinierter Ort", + "@customLocation": {}, + "appDirectory": "App-Verzeichnis", + "@appDirectory": {}, + "addDownloadLocation": "Download-Ort hinzufügen", + "@addDownloadLocation": {}, + "selectDirectory": "Verzeichnis auswählen", + "@selectDirectory": {}, + "pathReturnSlashErrorMessage": "Pfade die mit \"/\" antworten können nicht genutzt werden", + "@pathReturnSlashErrorMessage": {}, + "customLocationsBuggy": "Benutzerdefinierte Orte sind extrem fehlerbehaftet aufgrund von Problemen mit Berechtigungen. Ich denke über Möglichkeiten nach, um dies zu beheben, aber im Moment würde ich davon abraten, diese zu nutzen.", + "@customLocationsBuggy": {}, + "enterLowPriorityStateOnPause": "Niedrige Priorität-Modus beim Pausieren aktivieren", + "@enterLowPriorityStateOnPause": {}, + "shuffleAllSongCount": "Mische-Alle Titelanzahl", + "@shuffleAllSongCount": {}, + "shuffleAllSongCountSubtitle": "Die Anzahl der zu ladenden Titel wenn der Mische-Alle Knopf betätigt wird.", + "@shuffleAllSongCountSubtitle": {}, + "viewType": "Ansichtsart", + "@viewType": {}, + "viewTypeSubtitle": "Ansichtsart für den Musik-Bildschirm", + "@viewTypeSubtitle": {}, + "portrait": "Portrait", + "@portrait": {}, + "landscape": "Landschaft", + "@landscape": {}, + "gridCrossAxisCountSubtitle": "Menge der zu benutzenden Rasterkacheln wenn {value}.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "showTextOnGridViewSubtitle": "Ob oder nicht Text (Titel, Künstler etc.) auf dem Raster des Musikbildschirms angezeigt werden soll.", + "@showTextOnGridViewSubtitle": {}, + "showCoverAsPlayerBackground": "Zeige verschwommene Cover als Player-Hintergrund", + "@showCoverAsPlayerBackground": {}, + "showCoverAsPlayerBackgroundSubtitle": "Ob oder nicht verschwommene Cover als Hintergrund im Player-Bildschirm benutzt werden sollen.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "hideSongArtistsIfSameAsAlbumArtists": "Verstecke Titel-Künstler falls derselbe wie Album-Künstler", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "theme": "Thema", + "@theme": {}, + "system": "System", + "@system": {}, + "light": "Hell", + "@light": {}, + "dark": "Dunkel", + "@dark": {}, + "tabs": "Tabs", + "@tabs": {}, + "cancelSleepTimer": "Schlaf-Timer abbrechen?", + "@cancelSleepTimer": {}, + "setSleepTimer": "Schlaf-Timer einstellen", + "@setSleepTimer": {}, + "minutes": "Minuten", + "@minutes": {}, + "downloaded": "HERUNTERGELADEN", + "@downloaded": {}, + "transcode": "TRANSKODIERUNG", + "@transcode": {}, + "addToPlaylistTitle": "Zur Wiedergabeliste hinzufügen", + "@addToPlaylistTitle": {}, + "noAlbum": "Kein Album", + "@noAlbum": {}, + "noItem": "Kein Element", + "@noItem": {}, + "noArtist": "Kein Künstler", + "@noArtist": {}, + "streaming": "STREAMING", + "@streaming": {}, + "statusError": "STATUS FEHLER", + "@statusError": {}, + "queue": "Warteschlange", + "@queue": {}, + "addToQueue": "Zur Warteschlange hinzufügen", + "@addToQueue": {}, + "replaceQueue": "Warteschlange austauschen", + "@replaceQueue": {}, + "instantMix": "Sofort Mix", + "@instantMix": {}, + "goToAlbum": "Gehe zu Album", + "@goToAlbum": {}, + "addFavourite": "Favorit hinzufügen", + "@addFavourite": {}, + "addedToQueue": "Zur Warteschlange hinzugefügt.", + "@addedToQueue": {}, + "queueReplaced": "Warteschlange ausgetauscht.", + "@queueReplaced": {}, + "startingInstantMix": "Starte Sofort-Mix.", + "@startingInstantMix": {}, + "anErrorHasOccured": "Ein Fehler ist aufgetreten.", + "@anErrorHasOccured": {}, + "responseError": "{error} Status Code {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "discNumber": "CD {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "songCount": "{count,plural,=1{{count} Titel} other{{count} Titel}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "editPlaylistNameTooltip": "Wiedergabeliste-Name editieren", + "@editPlaylistNameTooltip": {}, + "playlistNameUpdated": "Wiedergabeliste-Name aktualisiert.", + "@playlistNameUpdated": {}, + "logsCopied": "Protokolle kopiert.", + "@logsCopied": {}, + "downloadsAdded": "Downloads hinzugefügt.", + "@downloadsAdded": {}, + "addButtonLabel": "Hinzufügen", + "@addButtonLabel": {}, + "applicationLegalese": "Lizensiert mit der Mozilla Public License 2.0. Quellcode verfügbar unter:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "enterLowPriorityStateOnPauseSubtitle": "Ermöglicht, dass die Benachrichtigung im pausierten Zustand weggewischt werden kann. Dies erlaubt es Android ebenfalls, den Prozess im pausierten Zustand zu terminieren.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "gridCrossAxisCount": "{value} Raster Querachsen-Anzahl", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Ob Titel-Künstler in der Albenansicht angezeigt werden sollen, falls sie sich nicht von den Album-Künstlern unterscheiden.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "sleepTimerTooltip": "Schlaf-Timer", + "@sleepTimerTooltip": {}, + "addToPlaylistTooltip": "Zur Wiedergabeliste hinzufügen", + "@addToPlaylistTooltip": {}, + "responseError401": "{error} Status Code {statusCode}. Dies bedeutet vermutlich, dass du den falschen Benutzernamen oder Passwort benutzt hast oder dass dein Client nicht länger authentifiziert ist.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "downloadedImagesCount": "{count,plural,=1{{count} Bild} other{{count} Bilder}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsCount": "{count,plural,=1{{count} Element} other{{count} Elemente}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "premiereDate": "Erscheinungsdatum", + "@premiereDate": {}, + "downloadedMissingImages": "{count,plural, =0{Keine fehlenden Bilder gefunden} =1{{count} fehlendes Bild heruntergeladen} other{{count} fehlende Bilder heruntergeladen}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "errorScreenError": "Beim Erstellen der Fehlerliste kam es zu einem Fehler! An dieser Stelle solltest du ein Fehlerticket auf GitHub erstellen und die App-Daten löschen", + "@errorScreenError": {}, + "addToMix": "Zu Mix hinzufügen", + "@addToMix": {}, + "removeFromMix": "Aus Mix entfernen", + "@removeFromMix": {}, + "redownloadedItems": "{count,plural, =0{Keine erneuten Downloads notwendig.} =1{{count} Element erneut heruntergeladen} other{{count} Elemente erneut heruntergeladen}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "bufferDuration": "Pufferdauer", + "@bufferDuration": {}, + "disableGesture": "Gesten deaktivieren", + "@disableGesture": {}, + "disableGestureSubtitle": "Ob Gesten deaktiviert werden sollen.", + "@disableGestureSubtitle": {}, + "removeFromPlaylistTooltip": "Aus Wiedergabeliste entfernen", + "@removeFromPlaylistTooltip": {}, + "removeFromPlaylistTitle": "Aus Wiedergabeliste entfernen", + "@removeFromPlaylistTitle": {}, + "removedFromPlaylist": "Aus der Wiedergabeliste entfernt.", + "@removedFromPlaylist": {}, + "bufferDurationSubtitle": "Wie viel gepuffert werden soll, in Sekunden. Neustart erforderlich.", + "@bufferDurationSubtitle": {}, + "language": "Sprache", + "@language": {}, + "confirm": "Bestätigen", + "@confirm": {}, + "showUncensoredLogMessage": "Dieses Protokoll enthält deine Anmeldedaten. Anzeigen?", + "@showUncensoredLogMessage": {}, + "resetTabs": "Tabs zurücksetzen", + "@resetTabs": {}, + "insertedIntoQueue": "Zur Warteschlange hinzufügen.", + "@insertedIntoQueue": { + "description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end." + }, + "deleteDownloadsPrompt": "Bist du sicher, dass du {itemType, select, album{album} playlist{playlist} artist{artist} genre{genre} track{song} other{}} '{itemName}' von diesem Gerät löschen willst?", + "@deleteDownloadsPrompt": { + "placeholders": { + "itemName": { + "type": "String", + "example": "Abandon Ship" + }, + "itemType": { + "type": "String", + "example": "album" + } + }, + "description": "Confirmation prompt shown before deleting downloaded media from the local device, destructive action, doesn't affect the media on the server." + }, + "deleteDownloadsAbortButtonText": "Abbrechen", + "@deleteDownloadsAbortButtonText": {}, + "deleteDownloadsConfirmButtonText": "Löschen", + "@deleteDownloadsConfirmButtonText": { + "description": "Shown in the confirmation dialog for deleting downloaded media from the local device." + }, + "syncDownloadedPlaylists": "Heruntergeladene Playlisten synchronisieren", + "@syncDownloadedPlaylists": {}, + "showFastScroller": "Schnellen Scroller anzeigen", + "@showFastScroller": {}, + "swipeInsertQueueNext": "Gewischtes Lied als Nächstes abspielen", + "@swipeInsertQueueNext": {}, + "swipeInsertQueueNextSubtitle": "Aktivieren, um das in der Liste nach links/rechts gewischte Lied als Nächstes abzuspielen, statt es am Ende der Warteschlange einzufügen.", + "@swipeInsertQueueNextSubtitle": {}, + "playNext": "Als Nächstes wiedergeben", + "@playNext": { + "description": "Popup menu item title for inserting an item into the play queue after the currently-playing item." + }, + "noMusicLibrariesTitle": "Keine Musik-Bibliotheken", + "@noMusicLibrariesTitle": { + "description": "Title for message that shows on the views screen when no music libraries could be found." + }, + "refresh": "AKTUALISIEREN", + "@refresh": {}, + "noMusicLibrariesBody": "Finamp konnte keine Musikbibliotheken finden. Bitte stelle sicher, dass dein Jellyfin-Server mindestens eine Bibliothek mit dem Medientyp \"Musik\" enthält.", + "@noMusicLibrariesBody": {}, + "interactions": "Interaktionen", + "@interactions": {}, + "redesignBeta": "Teste die Beta", + "@redesignBeta": {}, + "playbackOrderShuffledTooltip": "Mischen. Zum Umschalten tippen.", + "@playbackOrderShuffledTooltip": {}, + "togglePlayback": "Wiedergabe umschalten", + "@togglePlayback": {}, + "playbackOrderLinearTooltip": "Abspielen in Reihenfolge. Zum umschalten tippen.", + "@playbackOrderLinearTooltip": {}, + "loopModeAllTooltip": "Wiederhole alle. Zum umschalten tippen.", + "@loopModeAllTooltip": {}, + "loopModeOneTooltip": "Wiederhole einen. Zum umschalten tippen.", + "@loopModeOneTooltip": {}, + "skipToNext": "Springe zum nächsten Lied", + "@skipToNext": {}, + "skipToPrevious": "Springe zum vorherigen Lied", + "@skipToPrevious": {}, + "playArtist": "Spiele alle Alben von {artist}", + "@playArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "shuffleArtist": "Spiele alle Alben von {artist} in zufälliger Reihenfolge", + "@shuffleArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "download": "Herunterladen", + "@download": {}, + "about": "Über Finamp", + "@about": {}, + "downloadArtist": "Lade alle Alben von {artist} herunter", + "@downloadArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "loopModeNoneTooltip": "Wiederholt nicht. Zum umschalten tippen.", + "@loopModeNoneTooltip": {}, + "deleteFromDevice": "Vom Gerät Löschen", + "@deleteFromDevice": {}, + "sync": "Mit Server synchronisieren", + "@sync": {} +} diff --git a/lib/l10n/app_el.arb b/lib/l10n/app_el.arb new file mode 100644 index 0000000..1c7be67 --- /dev/null +++ b/lib/l10n/app_el.arb @@ -0,0 +1,385 @@ +{ + "startupError": "Ωχ, κάτι δεν πήγε καλά κατά την έναρξη της εφαρμογής. Ο αριθμός σφάλματος είναι:{error}\nΔημιούργησε ένα issue στο\ngithub.com/UnicornsOnLSD/finamp μαζί με ένα στιγμιότυπο οθόνης από αυτήν την σελίδα. Εάν το πρόβλημα παραμένει μπορείς να διαγράψεις τα δεδομένα της εφαρμογής με σκοπό την επαναφορά της.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "internalExternalIpExplanation": "Αν θέλεις να έχεις πρόσβαση στον jellyfin διακομιστή σου εξ-αποστασεως, πρέπει να χρησιμοποιήσεις την εξωτερική Διεύθυνση IP.\n\nΑν ο διακομιστής βρίσκεται σε πύλη HTTP(80/443), δεν είναι απαραίτητος ο ορισμός πύλης. Αυτό είναι πιθανόν εφόσον ο διακομιστής σου βρίσκεται πίσω από reverse proxy.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "artists": "Καλλιτέχνες", + "@artists": {}, + "next": "Επόμενο", + "@next": {}, + "emptyServerUrl": "Η διεύθυνση διακομιστή δεν μπορεί να είναι κενή", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "songs": "Τραγούδια", + "@songs": {}, + "urlStartWithHttps": "Η διεύθυνση πρέπει να ξεκινά με http:// ή https://", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "serverUrl": "Διεύθυνση διακομιστή", + "@serverUrl": {}, + "downloads": "Λήψεις", + "@downloads": {}, + "selectMusicLibraries": "Επέλεξε τις βιβλιοθήκες μουσικής σου", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "albums": "Άλμπουμ", + "@albums": {}, + "urlTrailingSlash": "Η διεύθυνση δεν πρέπει να περιέχει ακολουθούμενη κάθετο", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "username": "Όνομα χρήστη", + "@username": {}, + "password": "Συνθηματικό χρήστη", + "@password": {}, + "logs": "Αρχείο καταγραφών", + "@logs": {}, + "couldNotFindLibraries": "Δεν βρέθηκαν συλλογές.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "unknownName": "Άγνωστη όνομα", + "@unknownName": {}, + "genres": "Είδη", + "@genres": {}, + "startMixNoSongsArtist": "Κράτησε πατημένο έναν καλλιτέχνη, προκειμένου να προστεθεί ή να αφαιρεθεί από τον κατασκευαστή μίξεων, προ-εκκινήσεως μίξης", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "playlists": "Λίστες αναπαραγωγής", + "@playlists": {}, + "startMix": "Έναρξη μίξης", + "@startMix": {}, + "startMixNoSongsAlbum": "Κράτησε πατημένο ένα άλμπουμ, προκειμένου να προστεθεί ή να αφαιρεθεί από τον κατασκευαστή μίξεων, προ-εκκινήσεως μίξης", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "music": "Μουσική", + "@music": {}, + "finamp": "Finamp", + "@finamp": {}, + "communityRating": "Βαθμολογία κοινότητας", + "@communityRating": {}, + "dlFailed": "{count} απέτυχαν", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "clear": "Εκκαθάριση", + "@clear": {}, + "favourites": "Αγαπημένα", + "@favourites": {}, + "shuffleAll": "Τυχαία αναπαραγωγή", + "@shuffleAll": {}, + "sortOrder": "Σειρά ταξινόμησης", + "@sortOrder": {}, + "settings": "Ρυθμίσεις", + "@settings": {}, + "offlineMode": "Λειτουργία εκτός σύνδεσης", + "@offlineMode": {}, + "album": "Άλμπουμ", + "@album": {}, + "budget": "Προϋπολογισμός", + "@budget": {}, + "sortBy": "Ταξινόμηση κατά", + "@sortBy": {}, + "criticRating": "Βαθμολογία κριτών", + "@criticRating": {}, + "albumArtist": "Καλλιτέχνης άλμπουμ", + "@albumArtist": {}, + "artist": "Καλλιτέχνης", + "@artist": {}, + "dateAdded": "Ημερομηνία προσθήκης", + "@dateAdded": {}, + "playCount": "Πλήθος αναπαραγωγών", + "@playCount": {}, + "datePlayed": "Ημερομηνία αναπαραγωγής", + "@datePlayed": {}, + "random": "Τυχαίο", + "@random": {}, + "premiereDate": "Ημερομηνία πρεμιέρας", + "@premiereDate": {}, + "productionYear": "Χρονολογία παραγωγής", + "@productionYear": {}, + "revenue": "Έσοδα", + "@revenue": {}, + "downloadMissingImages": "Λήψη ελλείποντων εικόνων", + "@downloadMissingImages": {}, + "downloadedImagesCount": "{count,plural,=1{{count} εικόνα} other{{count} εικόνες}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "name": "Όνομα", + "@name": {}, + "downloadCount": "{count,plural, =1{{count} λήψη} other{{count} λήψεις}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlComplete": "{count} ολοκληρώθηκαν", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "failedToGetSongFromDownloadId": "Απέτυχε η λήψη τραγουδιού από το download ID", + "@failedToGetSongFromDownloadId": {}, + "playlistNameUpdated": "Ανανεώθηκε το όνομα λίστας αναπαραγωγής.", + "@playlistNameUpdated": {}, + "runtime": "Διάρκεια", + "@runtime": {}, + "noErrors": "Κανένα σφάλμα!", + "@noErrors": {}, + "stackTrace": "Stack Trace", + "@stackTrace": {}, + "downloadedMissingImages": "{count,plural, =0{δεν βρέθηκαν ελλειπείς εικόνες} =1{ελήφθησαν {count} ελλειπής εικόνες} other{ελήφθησαν {count} ελλειπής εικόνες}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrors": "Σφάλμα στην λήψη", + "@downloadErrors": {}, + "downloadedItemsCount": "{count,plural,=1{{count} αντικείμενο} other{{count} αντικείμενα}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "dlEnqueued": "{count} στην σειρά", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "{count} τρέχοντα", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "Σφάλματα λήψης", + "@downloadErrorsTitle": {}, + "error": "Σφάλμα", + "@error": {}, + "shuffleButtonLabel": "Τυχαία αναπαραγωγή", + "@shuffleButtonLabel": {}, + "errorScreenError": "Προέκυψε σφάλμα κατά την λήψη λίστας σφαλμάτων! Σε αυτό το σημείο, καλό είναι να δημιουργήσεις issue στο GitHub και να διαγράψεις τα δεδομένα της εφαρμογής", + "@errorScreenError": {}, + "discNumber": "Δίσκος {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "playButtonLabel": "Αναπαραγωγή", + "@playButtonLabel": {}, + "addDownloads": "Προσθήκη λήψεων", + "@addDownloads": {}, + "logsCopied": "Αντιγράφηκαν οι καταγραφές.", + "@logsCopied": {}, + "message": "Μύνημα", + "@message": {}, + "required": "Απαραίτητο", + "@required": {}, + "favourite": "Αγαπημένο", + "@favourite": {}, + "downloadsAdded": "Προστέθηκαν λήψεις.", + "@downloadsAdded": {}, + "audioService": "Audio Service", + "@audioService": {}, + "songCount": "{count,plural,=1{{count} τίτλος} other{{count} τίτλοι}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "editPlaylistNameTooltip": "Τροποποίηση λίστας αναπαραγωγής", + "@editPlaylistNameTooltip": {}, + "downloadsDeleted": "Διαγράφτηκαν οι λήψεις.", + "@downloadsDeleted": {}, + "editPlaylistNameTitle": "Τροποποίηση ονόματος λίστας", + "@editPlaylistNameTitle": {}, + "updateButtonLabel": "Ανανέωση", + "@updateButtonLabel": {}, + "transcoding": "Μετακωδικοποίηση", + "@transcoding": {}, + "notAvailableInOfflineMode": "Μη διαθέσιμο σε λειτουργία εκτός σύνδεσης", + "@notAvailableInOfflineMode": {}, + "logOut": "Έξοδος", + "@logOut": {}, + "downloadedSongsWillNotBeDeleted": "Κατεβασμένα τραγούδια δεν θα διαγραφούν", + "@downloadedSongsWillNotBeDeleted": {}, + "areYouSure": "Είσαι σίγουρος?", + "@areYouSure": {}, + "location": "Τοποθεσία", + "@location": {}, + "addButtonLabel": "Προσθήκη", + "@addButtonLabel": {}, + "layoutAndTheme": "Διάταξη καί εμφάνιση", + "@layoutAndTheme": {}, + "enableTranscodingSubtitle": "μετακωδικοποιει την ροή μουσικής από την πλευρά του διακομιστή.", + "@enableTranscodingSubtitle": {}, + "shareLogs": "Μοιραστείτε τις καταγραφές", + "@shareLogs": {}, + "applicationLegalese": "Αδειοδοτηθηκε με την Άδεια Δημόσιας Χρήσης της Mozilla Public License2.0. Ο κώδικας πηγής διαθέσιμος στο:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "downloadLocations": "Τοποθεσίες λήψεων", + "@downloadLocations": {}, + "jellyfinUsesAACForTranscoding": "Το Jellyfin χρησιμοποιεί AAC για την μετακωδικοποίηση", + "@jellyfinUsesAACForTranscoding": {}, + "enableTranscoding": "Ενεργοποίηση μετακωδικοποίησης", + "@enableTranscoding": {}, + "bitrate": "Bitrate", + "@bitrate": {}, + "bitrateSubtitle": "Ο ψηλότερος bitrate προσφέρει υψηλότερης ποιότητας ήχο σε βάρος εύρους ζώνης.", + "@bitrateSubtitle": {}, + "customLocation": "Εξατομικευμένη τοποθεσία", + "@customLocation": {}, + "customLocationsBuggy": "Οι εξατομικευμένες τοποθεσίες έχουν αρκετά bugs λόγω θεμάτων αδειών.\nΣκέφτομαι τρόπους να φτιάξω τα προβλήμματα, όμως για τώρα δεν προτείνω την χρήση τους.", + "@customLocationsBuggy": {}, + "enterLowPriorityStateOnPause": "Ενεργοποίηση χαμηλής-προτεραιοτητας κατάσταση κατά την παύση", + "@enterLowPriorityStateOnPause": {}, + "landscape": "Οριζόντιο", + "@landscape": {}, + "gridCrossAxisCount": "{value} πλήθος τεμνοντων αξόνων πλέγματος", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "appDirectory": "Κατάλογος εφαρμογής", + "@appDirectory": {}, + "addDownloadLocation": "Προσθήκη τοποθεσίας λήψεως", + "@addDownloadLocation": {}, + "selectDirectory": "Επέλεξε κατάλογο", + "@selectDirectory": {}, + "unknownError": "Άγνωστο σφάλμα", + "@unknownError": {}, + "directoryMustBeEmpty": "Ο κατάλογος πρέπει να είναι άδειος", + "@directoryMustBeEmpty": {}, + "enterLowPriorityStateOnPauseSubtitle": "Επιτρέπει την απόσβεση της ειδοποίησης κατά την παύση. Επίσης επιτρέπει στο σύστημα να \"σκοτώνει\" την υπηρεσία κατά την παύση.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "shuffleAllSongCountSubtitle": "Πλήθος τραγουδιών να φορτώσουν όταν χρησιμοποιείται το κουμπί τυχαίας αναπαραγωγής όλων.", + "@shuffleAllSongCountSubtitle": {}, + "viewTypeSubtitle": "Τρόπος προβολής για την οθόνη μουσικής", + "@viewTypeSubtitle": {}, + "list": "Λίστα", + "@list": {}, + "pathReturnSlashErrorMessage": "Μονοπάτια που επιστρέφουν \"/\" δεν μπορούν να χρησιμοποιηθούν", + "@pathReturnSlashErrorMessage": {}, + "shuffleAllSongCount": "Τυχαία αναπαραγωγή όλων των Πλήθων τραγουδιών", + "@shuffleAllSongCount": {}, + "viewType": "Τύπος προβολής", + "@viewType": {}, + "grid": "Πλέγμα", + "@grid": {}, + "portrait": "Κάθετο", + "@portrait": {}, + "showTextOnGridView": "Εμφάνιση αναγραφων στην προβολή πλέγματος", + "@showTextOnGridView": {}, + "gridCrossAxisCountSubtitle": "Πλήθος τετραγώνων πλέγματος ανά Πλειάδες{value}.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "theme": "Θέμα", + "@theme": {}, + "system": "Σύστημα", + "@system": {}, + "light": "Φωτεινό", + "@light": {}, + "dark": "Σκοτεινό", + "@dark": {}, + "tabs": "Καρτέλες", + "@tabs": {}, + "cancelSleepTimer": "Ακύρωση χρονοδιακόπτη ύπνου;", + "@cancelSleepTimer": {}, + "setSleepTimer": "Ρύθμιση Χρονοδιακόπτη Ύπνου", + "@setSleepTimer": {}, + "minutes": "Λεπτά", + "@minutes": {}, + "sleepTimerTooltip": "Χρονοδιακόπτης Ύπνου", + "@sleepTimerTooltip": {}, + "addToPlaylistTooltip": "Προσθήκη στην λίστα αναπαραγωγής", + "@addToPlaylistTooltip": {}, + "newPlaylist": "Νέα Λίστα Αναπαραγωγής", + "@newPlaylist": {}, + "addToPlaylistTitle": "Προσθήκη στην Λίστα Αναπαραγωγής", + "@addToPlaylistTitle": {}, + "playlistCreated": "Η Λίστα Αναπαραγωγής δημιουργήθηκε.", + "@playlistCreated": {}, + "removeFromPlaylistTooltip": "Αφαίρεση από την λίστα αναπαραγωγής", + "@removeFromPlaylistTooltip": {}, + "removeFromPlaylistTitle": "Αφαίρεση από την Λίστα Αναπαραγωγής", + "@removeFromPlaylistTitle": {}, + "createButtonLabel": "ΔΗΜΙΟΥΡΓΙΑ", + "@createButtonLabel": {}, + "invalidNumber": "Μη Έγκυρος Αριθμός", + "@invalidNumber": {}, + "yesButtonLabel": "ΝΑΙ", + "@yesButtonLabel": {}, + "noButtonLabel": "ΟΧΙ", + "@noButtonLabel": {} +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 0000000..0442e75 --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,587 @@ +{ + "startupError": "Something went wrong during app startup. The error was: {error}\n\nPlease create an issue on github.com/UnicornsOnLSD/finamp with a screenshot of this page. If this problem persists you can clear your app data to reset the app.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "serverUrl": "Server URL", + "@serverUrl": {}, + "internalExternalIpExplanation": "If you want to be able to access your Jellyfin server remotely, you need to use your external IP.\n\nIf your server is on a HTTP port (80/443), you don't have to specify a port. This will likely be the case if your server is behind a reverse proxy.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "emptyServerUrl": "Server URL cannot be empty", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "urlStartWithHttps": "URL must start with http:// or https://", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "urlTrailingSlash": "URL must not include a trailing slash", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "username": "Username", + "@username": {}, + "password": "Password", + "@password": {}, + "logs": "Logs", + "@logs": {}, + "next": "Next", + "@next": {}, + "selectMusicLibraries": "Select Music Libraries", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "couldNotFindLibraries": "Could not find any libraries.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "unknownName": "Unknown Name", + "@unknownName": {}, + "songs": "Songs", + "@songs": {}, + "albums": "Albums", + "@albums": {}, + "artists": "Artists", + "@artists": {}, + "genres": "Genres", + "@genres": {}, + "playlists": "Playlists", + "@playlists": {}, + "startMix": "Start Mix", + "@startMix": {}, + "startMixNoSongsArtist": "Long-press an artist to add or remove it from the mix builder before starting a mix", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "startMixNoSongsAlbum": "Long-press an album to add or remove it from the mix builder before starting a mix", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "music": "Music", + "@music": {}, + "clear": "Clear", + "@clear": {}, + "favourites": "Favourites", + "@favourites": {}, + "shuffleAll": "Shuffle all", + "@shuffleAll": {}, + "finamp": "Finamp", + "@finamp": {}, + "downloads": "Downloads", + "@downloads": {}, + "settings": "Settings", + "@settings": {}, + "offlineMode": "Offline Mode", + "@offlineMode": {}, + "sortOrder": "Sort order", + "@sortOrder": {}, + "sortBy": "Sort by", + "@sortBy": {}, + "album": "Album", + "@album": {}, + "albumArtist": "Album Artist", + "@albumArtist": {}, + "artist": "Artist", + "@artist": {}, + "budget": "Budget", + "@budget": {}, + "communityRating": "Community Rating", + "@communityRating": {}, + "criticRating": "Critic Rating", + "@criticRating": {}, + "dateAdded": "Date Added", + "@dateAdded": {}, + "datePlayed": "Date Played", + "@datePlayed": {}, + "playCount": "Play Count", + "@playCount": {}, + "premiereDate": "Premiere Date", + "@premiereDate": {}, + "productionYear": "Production Year", + "@productionYear": {}, + "name": "Name", + "@name": {}, + "random": "Random", + "@random": {}, + "revenue": "Revenue", + "@revenue": {}, + "runtime": "Runtime", + "@runtime": {}, + "syncDownloadedPlaylists": "Sync downloaded playlists", + "@syncDownloadedPlaylists": {}, + "downloadMissingImages": "Download missing images", + "@downloadMissingImages": {}, + "downloadedMissingImages": "{count,plural, =0{No missing images found} =1{Downloaded {count} missing image} other{Downloaded {count} missing images}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrors": "Download errors", + "@downloadErrors": {}, + "downloadCount": "{count,plural, =1{{count} download} other{{count} downloads}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsCount": "{count,plural,=1{{count} item} other{{count} items}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedImagesCount": "{count,plural,=1{{count} image} other{{count} images}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "dlComplete": "{count} complete", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlFailed": "{count} failed", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlEnqueued": "{count} enqueued", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "{count} running", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "Download Errors", + "@downloadErrorsTitle": {}, + "noErrors": "No errors!", + "@noErrors": {}, + "errorScreenError": "An error occurred while getting the list of errors! At this point, you should probably just create an issue on GitHub and delete app data", + "@errorScreenError": {}, + "failedToGetSongFromDownloadId": "Failed to get song from download ID", + "@failedToGetSongFromDownloadId": {}, + "deleteDownloadsPrompt": "Are you sure you want to delete the {itemType, select, album{album} playlist{playlist} artist{artist} genre{genre} track{song} other{}} '{itemName}' from this device?", + "@deleteDownloadsPrompt": { + "placeholders": { + "itemName": { + "type": "String", + "example": "Abandon Ship" + }, + "itemType": { + "type": "String", + "example": "album" + } + }, + "description": "Confirmation prompt shown before deleting downloaded media from the local device, destructive action, doesn't affect the media on the server." + }, + "deleteDownloadsConfirmButtonText": "Delete", + "@deleteDownloadsConfirmButtonText": { + "description": "Shown in the confirmation dialog for deleting downloaded media from the local device." + }, + "deleteDownloadsAbortButtonText": "Cancel", + "error": "Error", + "@error": {}, + "discNumber": "Disc {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "playButtonLabel": "PLAY", + "@playButtonLabel": {}, + "shuffleButtonLabel": "SHUFFLE", + "@shuffleButtonLabel": {}, + "songCount": "{count,plural,=1{{count} Song} other{{count} Songs}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "editPlaylistNameTooltip": "Edit playlist name", + "@editPlaylistNameTooltip": {}, + "editPlaylistNameTitle": "Edit Playlist Name", + "@editPlaylistNameTitle": {}, + "required": "Required", + "@required": {}, + "updateButtonLabel": "UPDATE", + "@updateButtonLabel": {}, + "playlistNameUpdated": "Playlist name updated.", + "@playlistNameUpdated": {}, + "favourite": "Favourite", + "@favourite": {}, + "downloadsDeleted": "Downloads deleted.", + "@downloadsDeleted": {}, + "addDownloads": "Add Downloads", + "@addDownloads": {}, + "location": "Location", + "@location": {}, + "downloadsAdded": "Downloads added.", + "@downloadsAdded": {}, + "addButtonLabel": "ADD", + "@addButtonLabel": {}, + "shareLogs": "Share logs", + "@shareLogs": {}, + "logsCopied": "Logs copied.", + "@logsCopied": {}, + "message": "Message", + "@message": {}, + "stackTrace": "Stack Trace", + "@stackTrace": {}, + "applicationLegalese": "Licensed with the Mozilla Public License 2.0. Source code available at:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "transcoding": "Transcoding", + "@transcoding": {}, + "downloadLocations": "Download Locations", + "@downloadLocations": {}, + "audioService": "Audio Service", + "@audioService": {}, + "interactions": "Interactions", + "@interactions": {}, + "layoutAndTheme": "Layout & Theme", + "@layoutAndTheme": {}, + "notAvailableInOfflineMode": "Not available in offline mode", + "@notAvailableInOfflineMode": {}, + "logOut": "Log Out", + "@logOut": {}, + "downloadedSongsWillNotBeDeleted": "Downloaded songs will not be deleted", + "@downloadedSongsWillNotBeDeleted": {}, + "areYouSure": "Are you sure?", + "@areYouSure": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin uses AAC for transcoding", + "@jellyfinUsesAACForTranscoding": {}, + "enableTranscoding": "Enable Transcoding", + "@enableTranscoding": {}, + "enableTranscodingSubtitle": "Transcodes music streams on the server side.", + "@enableTranscodingSubtitle": {}, + "bitrate": "Bitrate", + "@bitrate": {}, + "bitrateSubtitle": "A higher bitrate gives higher quality audio at the cost of higher bandwidth.", + "@bitrateSubtitle": {}, + "customLocation": "Custom Location", + "@customLocation": {}, + "appDirectory": "App Directory", + "@appDirectory": {}, + "addDownloadLocation": "Add Download Location", + "@addDownloadLocation": {}, + "selectDirectory": "Select Directory", + "@selectDirectory": {}, + "unknownError": "Unknown Error", + "@unknownError": {}, + "pathReturnSlashErrorMessage": "Paths that return \"/\" can't be used", + "@pathReturnSlashErrorMessage": {}, + "directoryMustBeEmpty": "Directory must be empty", + "@directoryMustBeEmpty": {}, + "customLocationsBuggy": "Custom locations are extremely buggy due to issues with permissions. I'm thinking of ways to fix this, but for now I wouldn't recommend using them.", + "@customLocationsBuggy": {}, + "enterLowPriorityStateOnPause": "Enter Low-Priority State on Pause", + "@enterLowPriorityStateOnPause": {}, + "enterLowPriorityStateOnPauseSubtitle": "Lets the notification be swiped away when paused. Also allows Android to kill the service when paused.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "shuffleAllSongCount": "Shuffle All Song Count", + "@shuffleAllSongCount": {}, + "shuffleAllSongCountSubtitle": "Amount of songs to load when using the shuffle all songs button.", + "@shuffleAllSongCountSubtitle": {}, + "viewType": "View Type", + "@viewType": {}, + "viewTypeSubtitle": "View type for the music screen", + "@viewTypeSubtitle": {}, + "list": "List", + "@list": {}, + "grid": "Grid", + "@grid": {}, + "portrait": "Portrait", + "@portrait": {}, + "landscape": "Landscape", + "@landscape": {}, + "gridCrossAxisCount": "{value} Grid Cross-Axis Count", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "gridCrossAxisCountSubtitle": "Amount of grid tiles to use per-row when {value}.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "showTextOnGridView": "Show text in grid view", + "@showTextOnGridView": {}, + "showTextOnGridViewSubtitle": "Whether or not to show the text (title, artist etc) on the grid music screen.", + "@showTextOnGridViewSubtitle": {}, + "showCoverAsPlayerBackground": "Show blurred cover as player background", + "@showCoverAsPlayerBackground": {}, + "showCoverAsPlayerBackgroundSubtitle": "Whether or not to use blurred cover art as background on player screen.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "hideSongArtistsIfSameAsAlbumArtists": "Hide song artists if same as album artists", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Whether to show song artists on the album screen if not differing from album artists.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "disableGesture": "Disable gestures", + "@disableGesture": {}, + "disableGestureSubtitle": "Whether to disables gestures.", + "@disableGestureSubtitle": {}, + "showFastScroller": "Show fast scroller", + "@showFastScroller": {}, + "theme": "Theme", + "@theme": {}, + "system": "System", + "@system": {}, + "light": "Light", + "@light": {}, + "dark": "Dark", + "@dark": {}, + "tabs": "Tabs", + "@tabs": {}, + "cancelSleepTimer": "Cancel Sleep Timer?", + "@cancelSleepTimer": {}, + "yesButtonLabel": "YES", + "@yesButtonLabel": {}, + "noButtonLabel": "NO", + "@noButtonLabel": {}, + "setSleepTimer": "Set Sleep Timer", + "@setSleepTimer": {}, + "minutes": "Minutes", + "@minutes": {}, + "invalidNumber": "Invalid Number", + "@invalidNumber": {}, + "sleepTimerTooltip": "Sleep timer", + "@sleepTimerTooltip": {}, + "addToPlaylistTooltip": "Add to playlist", + "@addToPlaylistTooltip": {}, + "addToPlaylistTitle": "Add to Playlist", + "@addToPlaylistTitle": {}, + "removeFromPlaylistTooltip": "Remove from playlist", + "@removeFromPlaylistTooltip": {}, + "removeFromPlaylistTitle": "Remove from Playlist", + "@removeFromPlaylistTitle": {}, + "newPlaylist": "New Playlist", + "@newPlaylist": {}, + "createButtonLabel": "CREATE", + "@createButtonLabel": {}, + "playlistCreated": "Playlist created.", + "@playlistCreated": {}, + "noAlbum": "No Album", + "@noAlbum": {}, + "noItem": "No Item", + "@noItem": {}, + "noArtist": "No Artist", + "@noArtist": {}, + "unknownArtist": "Unknown Artist", + "@unknownArtist": {}, + "streaming": "STREAMING", + "@streaming": {}, + "downloaded": "DOWNLOADED", + "@downloaded": {}, + "transcode": "TRANSCODE", + "@transcode": {}, + "direct": "DIRECT", + "@direct": {}, + "statusError": "STATUS ERROR", + "@statusError": {}, + "queue": "Queue", + "@queue": {}, + "addToQueue": "Add to Queue", + "@addToQueue": { + "description": "Popup menu item title for adding an item to the end of the play queue." + }, + "playNext": "Play Next", + "@playNext": { + "description": "Popup menu item title for inserting an item into the play queue after the currently-playing item." + }, + "replaceQueue": "Replace Queue", + "@replaceQueue": {}, + "instantMix": "Instant Mix", + "@instantMix": {}, + "goToAlbum": "Go to Album", + "@goToAlbum": {}, + "removeFavourite": "Remove Favourite", + "@removeFavourite": {}, + "addFavourite": "Add Favourite", + "@addFavourite": {}, + "addedToQueue": "Added to queue.", + "@addedToQueue": { + "description": "Snackbar message that shows when the user successfully adds items to the end of the play queue." + }, + "insertedIntoQueue": "Inserted into queue.", + "@insertedIntoQueue": { + "description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end." + }, + "queueReplaced": "Queue replaced.", + "@queueReplaced": {}, + "removedFromPlaylist": "Removed from playlist.", + "@removedFromPlaylist": {}, + "startingInstantMix": "Starting instant mix.", + "@startingInstantMix": {}, + "anErrorHasOccured": "An error has occured.", + "@anErrorHasOccured": {}, + "responseError": "{error} Status code {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "responseError401": "{error} Status code {statusCode}. This probably means you used the wrong username/password, or your client is no longer logged in.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "removeFromMix": "Remove From Mix", + "@removeFromMix": {}, + "addToMix": "Add To Mix", + "@addToMix": {}, + "redownloadedItems": "{count,plural, =0{No redownloads needed.} =1{Redownloaded {count} item} other{Redownloaded {count} items}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "bufferDuration": "Buffer Duration", + "@bufferDuration": {}, + "bufferDurationSubtitle": "How much the player should buffer, in seconds. Requires a restart.", + "@bufferDurationSubtitle": {}, + "language": "Language", + "confirm": "Confirm", + "showUncensoredLogMessage": "This log contains your login information. Show?", + "resetTabs": "Reset tabs", + "noMusicLibrariesTitle": "No Music Libraries", + "@noMusicLibrariesTitle": { + "description": "Title for message that shows on the views screen when no music libraries could be found." + }, + "noMusicLibrariesBody": "Finamp could not find any music libraries. Please ensure that your Jellyfin server contains at least one library with the content type set to \"Music\".", + "refresh": "REFRESH", + "swipeInsertQueueNext": "Play Swiped Song Next", + "@swipeInsertQueueNext": {}, + "swipeInsertQueueNextSubtitle": "Enable to insert a song as next item in queue when swiped in song list instead of appending it to the end.", + "@swipeInsertQueueNextSubtitle": {}, + "redesignBeta": "Try the Beta", + "@redesignBeta": {}, + "playbackOrderShuffledTooltip": "Shuffling. Tap to toggle.", + "@playbackOrderShuffledTooltip": {}, + "playbackOrderLinearTooltip": "Playing in order. Tap to toggle.", + "@playbackOrderLinearTooltip": {}, + "loopModeAllTooltip": "Looping all. Tap to toggle.", + "@loopModeAllTooltip": {}, + "loopModeOneTooltip": "Looping one. Tap to toggle.", + "@loopModeOneTooltip": {}, + "loopModeNoneTooltip": "Not looping. Tap to toggle.", + "@loopModeNoneTooltip": {}, + "skipToPrevious": "Skip to Previous Song", + "@skipToPrevious": {}, + "skipToNext": "Skip to Next Song", + "@skipToNext": {}, + "togglePlayback": "Toggle Playback", + "@togglePlayback": {}, + "playArtist": "Play all albums by {artist}", + "@playArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "shuffleArtist": "Shuffle all albums by {artist}", + "@shuffleArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "downloadArtist": "Download all albums by {artist}", + "@downloadArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "deleteFromDevice": "Delete from Device", + "@deleteFromDevice": {}, + "download": "Download", + "@download": {}, + "sync": "Synchronize with Server", + "@sync": {}, + "about": "About Finamp", + "@about": {} +} diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb new file mode 100644 index 0000000..c6b9407 --- /dev/null +++ b/lib/l10n/app_es.arb @@ -0,0 +1,590 @@ +{ + "addDownloads": "Añadir descargas", + "@addDownloads": {}, + "artists": "Artistas", + "@artists": {}, + "album": "Álbum", + "@album": {}, + "albumArtist": "Artista del álbum", + "@albumArtist": {}, + "artist": "Artista", + "@artist": {}, + "addButtonLabel": "AÑADIR", + "@addButtonLabel": {}, + "areYouSure": "¿Estás seguro?", + "@areYouSure": {}, + "addDownloadLocation": "Añadir ubicación de descarga", + "@addDownloadLocation": {}, + "appDirectory": "Directorio de la aplicación", + "@appDirectory": {}, + "addToPlaylistTitle": "Agregar a lista de reproducción", + "@addToPlaylistTitle": {}, + "addedToQueue": "Añadido a la cola.", + "@addedToQueue": {}, + "addToQueue": "Añadir a la cola", + "@addToQueue": {}, + "responseError": "{error} Código de estado {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "anErrorHasOccured": "Ha ocurrido un error.", + "@anErrorHasOccured": {}, + "addFavourite": "Añadir favorito", + "@addFavourite": {}, + "addToPlaylistTooltip": "Añadir a lista de reproducción", + "@addToPlaylistTooltip": {}, + "albums": "Álbumes", + "@albums": {}, + "songCount": "{count,plural,=1{{count} Canción} other{{count} Canciones}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "directoryMustBeEmpty": "El directorio debe estar vacío", + "@directoryMustBeEmpty": {}, + "couldNotFindLibraries": "No se pudo encontrar ninguna biblioteca.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "budget": "Presupuesto", + "@budget": {}, + "communityRating": "Valoración de la comunidad", + "@communityRating": {}, + "dateAdded": "Fecha de añadido", + "@dateAdded": {}, + "discNumber": "Disco {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "bitrate": "Tasa de bits", + "@bitrate": {}, + "customLocation": "Ubicación personalizada", + "@customLocation": {}, + "dark": "Oscuro", + "@dark": {}, + "createButtonLabel": "CREAR", + "@createButtonLabel": {}, + "direct": "DIRECTO", + "@direct": {}, + "cancelSleepTimer": "¿Cancelar el temporizador de sueño?", + "@cancelSleepTimer": {}, + "downloaded": "DESCARGADO", + "@downloaded": {}, + "dlEnqueued": "{count} en cola", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsCount": "{count,plural,=1{{count} elemento} other{{count} elementos}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedSongsWillNotBeDeleted": "Las canciones descargadas no serán eliminadas", + "@downloadedSongsWillNotBeDeleted": {}, + "downloads": "Descargas", + "@downloads": {}, + "goToAlbum": "Ir al álbum", + "@goToAlbum": {}, + "finamp": "Finamp", + "@finamp": {}, + "landscape": "Horizontal", + "@landscape": {}, + "noAlbum": "Sin álbum", + "@noAlbum": {}, + "noItem": "Ningún elemento", + "@noItem": {}, + "notAvailableInOfflineMode": "No está disponible en el modo sin conexión", + "@notAvailableInOfflineMode": {}, + "playlistCreated": "Se creó la lista.", + "@playlistCreated": {}, + "logs": "Registros", + "@logs": {}, + "password": "Contraseña", + "@password": {}, + "next": "Siguiente", + "@next": {}, + "favourites": "Favoritos", + "@favourites": {}, + "genres": "Géneros", + "@genres": {}, + "downloadErrors": "Errores en la descarga", + "@downloadErrors": {}, + "downloadMissingImages": "Descargar las imágenes que faltan", + "@downloadMissingImages": {}, + "dlComplete": "{count} completadas", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "editPlaylistNameTooltip": "Editar el nombre de la lista de reproducción", + "@editPlaylistNameTooltip": {}, + "playlistNameUpdated": "Se actualizó el nombre de la lista.", + "@playlistNameUpdated": {}, + "downloadsAdded": "Se añadieron las descargas.", + "@downloadsAdded": {}, + "applicationLegalese": "Licenciado con la Mozilla Public License 2.0. El código fuente disponible en:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "logsCopied": "Se copiaron los registros.", + "@logsCopied": {}, + "message": "Mensaje", + "@message": {}, + "downloadLocations": "Ubicaciones de descarga", + "@downloadLocations": {}, + "enableTranscoding": "Habilitar transcodificación", + "@enableTranscoding": {}, + "layoutAndTheme": "Diseño y tema", + "@layoutAndTheme": {}, + "light": "Claro", + "@light": {}, + "invalidNumber": "Número inválido", + "@invalidNumber": {}, + "instantMix": "Mezcla instantánea", + "@instantMix": {}, + "queue": "Cola", + "@queue": {}, + "replaceQueue": "Reemplazar la cola", + "@replaceQueue": {}, + "downloadErrorsTitle": "Error al descargar", + "@downloadErrorsTitle": {}, + "downloadsDeleted": "Se borraron las descargas.", + "@downloadsDeleted": {}, + "editPlaylistNameTitle": "Modificar el nombre de la lista de reproducción", + "@editPlaylistNameTitle": {}, + "error": "Error", + "@error": {}, + "favourite": "Favorito", + "@favourite": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin usa AAC para transcodificar", + "@jellyfinUsesAACForTranscoding": {}, + "list": "Lista", + "@list": {}, + "location": "Ubicación", + "@location": {}, + "name": "Nombre", + "@name": {}, + "logOut": "Cerrar sesión", + "@logOut": {}, + "music": "Música", + "@music": {}, + "newPlaylist": "Nueva lista", + "@newPlaylist": {}, + "noArtist": "Sin artista", + "@noArtist": {}, + "noButtonLabel": "NO", + "@noButtonLabel": {}, + "portrait": "Vertical", + "@portrait": {}, + "queueReplaced": "Se reemplazó la cola.", + "@queueReplaced": {}, + "noErrors": "¡Sin errores!", + "@noErrors": {}, + "playButtonLabel": "REPRODUCIR", + "@playButtonLabel": {}, + "removeFavourite": "Eliminar favorito", + "@removeFavourite": {}, + "yesButtonLabel": "SÍ", + "@yesButtonLabel": {}, + "offlineMode": "Modo sin conexión", + "@offlineMode": {}, + "playlists": "Listas", + "@playlists": {}, + "productionYear": "Año de producción", + "@productionYear": {}, + "random": "Aleatorio", + "@random": {}, + "clear": "Limpiar", + "@clear": {}, + "urlStartWithHttps": "La URL debe empezar por http:// o https://", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "serverUrl": "URL del servidor", + "@serverUrl": {}, + "emptyServerUrl": "La URL del servidor no puede estar vacia", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "urlTrailingSlash": "La URL no debe incluir una barra al final", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "username": "Nombre de usuario", + "@username": {}, + "songs": "Canciones", + "@songs": {}, + "unknownName": "Nombre desconocido", + "@unknownName": {}, + "selectMusicLibraries": "Seleccionar las bibliotecas de música", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "startMix": "Empezar mezcla", + "@startMix": {}, + "startMixNoSongsAlbum": "Mantén pulsado un álbum para añadirlo o eliminarlo del creador de mezclas antes de empezar una mezcla", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "startMixNoSongsArtist": "Mantén pulsado un artista para añadirlo o eliminarlo del creador de mezclas antes de empezar una mezcla", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "criticRating": "Valoración de los críticos", + "@criticRating": {}, + "sortBy": "Ordenar por", + "@sortBy": {}, + "sortOrder": "Ordenar", + "@sortOrder": {}, + "datePlayed": "Fecha de reproducción", + "@datePlayed": {}, + "settings": "Ajustes", + "@settings": {}, + "playCount": "Reproducciones", + "@playCount": {}, + "dlFailed": "{count} han fallado", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedImagesCount": "{count,plural,=1{{count} imagen} other{{count} imágenes}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "{count} en progreso", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "failedToGetSongFromDownloadId": "No se ha podido obtener la canción desde el identificador de descarga", + "@failedToGetSongFromDownloadId": {}, + "required": "Requerido", + "@required": {}, + "shuffleButtonLabel": "ALEATORIO", + "@shuffleButtonLabel": {}, + "updateButtonLabel": "ACTUALIZAR", + "@updateButtonLabel": {}, + "stackTrace": "Trazado de pila", + "@stackTrace": {}, + "shareLogs": "Compartir registros", + "@shareLogs": {}, + "enableTranscodingSubtitle": "Transcodifica los streams de música en el servidor.", + "@enableTranscodingSubtitle": {}, + "audioService": "Servicio de audio", + "@audioService": {}, + "transcoding": "Transcodificar", + "@transcoding": {}, + "bitrateSubtitle": "Una tasa de bits más alta da una mayor calidad de audio a costa de un mayor ancho de banda.", + "@bitrateSubtitle": {}, + "selectDirectory": "Seleccionar directorio", + "@selectDirectory": {}, + "unknownError": "Error desconocido", + "@unknownError": {}, + "enterLowPriorityStateOnPause": "Entrar en estado de baja prioridad al pausar", + "@enterLowPriorityStateOnPause": {}, + "pathReturnSlashErrorMessage": "No se pueden utilizar ubicaciones que contengan \"/\"", + "@pathReturnSlashErrorMessage": {}, + "grid": "Cuadrícula", + "@grid": {}, + "gridCrossAxisCount": "Columnas en vista de cuadrícula en {value}", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "viewType": "Tipo de vista", + "@viewType": {}, + "viewTypeSubtitle": "Tipo de vista para la pantalla de música", + "@viewTypeSubtitle": {}, + "showCoverAsPlayerBackground": "Mostrar la carátula con desenfoque como fondo del reproductor", + "@showCoverAsPlayerBackground": {}, + "tabs": "Pestañas", + "@tabs": {}, + "theme": "Tema", + "@theme": {}, + "system": "Sistema", + "@system": {}, + "setSleepTimer": "Ajustar temporizador de sueño", + "@setSleepTimer": {}, + "sleepTimerTooltip": "Temporizador de sueño", + "@sleepTimerTooltip": {}, + "unknownArtist": "Artista desconocido", + "@unknownArtist": {}, + "transcode": "TRANSCODIFICADO", + "@transcode": {}, + "streaming": "TRANSMITIENDO", + "@streaming": {}, + "startingInstantMix": "Empezando mezcla instantánea.", + "@startingInstantMix": {}, + "downloadCount": "{count,plural, =1{{count} descarga} other{{count} descargas}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "internalExternalIpExplanation": "Si quieres acceder a tu servidor Jellyfin remotamente, tendrás que usar tu dirección IP externa.\n\nSi tu servidor está en un puerto HTTP (80/443), no tendrás que especificar el puerto. Este probablemente sea tu caso si tu servidor está detrás de un proxy inverso.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "hideSongArtistsIfSameAsAlbumArtists": "Ocultar los artistas de canciones si son los mismos que los artistas del álbum", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "enterLowPriorityStateOnPauseSubtitle": "Permite que la notificación se pueda deslizar cuando la música esté pausada. También permite a Android matar el servicio cuando la música esté pausada.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "showTextOnGridView": "Mostrar texto en vista de cuadrícula", + "@showTextOnGridView": {}, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "startupError": "¡Algo ha salido mal en el arranque de la aplicación! El error es: {error}\n\nPor favor crea una incidencia en github.com/UnicornsOnLSD/finamp con una captura de pantalla de esta página. Si el problema persiste, borra los datos de la aplicación para resetearla.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "shuffleAll": "Reproducir aleatoriamente todas las canciones", + "@shuffleAll": {}, + "premiereDate": "Fecha de lanzamiento", + "@premiereDate": {}, + "revenue": "Ingresos", + "@revenue": {}, + "runtime": "Duración", + "@runtime": {}, + "downloadedMissingImages": "{count,plural, =0{No se han encontrado imágenes que faltan} =1{Descargada {count} imagen que faltaba} other{Descargadas {count} que faltaban}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "errorScreenError": "¡Se ha producido un error al obtener la lista de errores! Llegados a este punto, probablemente deberías crear una incidencia en GitHub y eliminar los datos de la aplicación", + "@errorScreenError": {}, + "customLocationsBuggy": "Las ubicaciones personalizadas son extremadamente problemáticas por problemas con los permisos. Estoy pensando en maneras de arreglarlo, pero por ahora no recomiendo usarlas.", + "@customLocationsBuggy": {}, + "shuffleAllSongCount": "Cantidad de canciones al reproducir aleatoriamente", + "@shuffleAllSongCount": {}, + "gridCrossAxisCountSubtitle": "Cantidad de columnas al usar por fila al estar el dispositivo en {value}.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "shuffleAllSongCountSubtitle": "Cantidad de canciones a cargar cuando se use el botón de reproducir todo aleatoriamente.", + "@shuffleAllSongCountSubtitle": {}, + "showTextOnGridViewSubtitle": "Muestra el texto (título, artista, etc) en la pantalla de música al usar la vista de cuadrícula.", + "@showTextOnGridViewSubtitle": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Oculta a los artistas de las canciones en la pantalla del álbum si no son diferentes de los artistas del álbum.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "statusError": "ERROR DE ESTADO", + "@statusError": {}, + "showCoverAsPlayerBackgroundSubtitle": "Usa la carátula del álbum desenfocada como fondo del reproductor.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "responseError401": "{error} Código de estado {statusCode}. Esto posiblemente significa que has usado el nombre de usuario o la contraseña incorrecta, o que tu cliente ya no está autenticado.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "removeFromMix": "Eliminar de la mezcla", + "@removeFromMix": {}, + "addToMix": "Añadir a la mezcla", + "@addToMix": {}, + "minutes": "Minutos", + "@minutes": {}, + "redownloadedItems": "{count,plural, =0{No hace falta volver a descargar nada.} =1{Redescargado {count} elemento} other{Redescargados {count} elementos}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "bufferDuration": "Duración del búfer", + "@bufferDuration": {}, + "bufferDurationSubtitle": "Cuánto debe almacenar en el búfer el reproductor, en segundos. Requiere un reinicio.", + "@bufferDurationSubtitle": {}, + "disableGesture": "Deshabilitar los gestos", + "@disableGesture": {}, + "disableGestureSubtitle": "Si desea desactivar los gestos.", + "@disableGestureSubtitle": {}, + "removeFromPlaylistTooltip": "Eliminar de la lista de reproducción", + "@removeFromPlaylistTooltip": {}, + "removeFromPlaylistTitle": "Borrar de la lista de reproducción", + "@removeFromPlaylistTitle": {}, + "removedFromPlaylist": "Eliminado de la lista de reproducción.", + "@removedFromPlaylist": {}, + "language": "Idioma", + "@language": {}, + "confirm": "Confirmar", + "@confirm": {}, + "showUncensoredLogMessage": "Este registro contiene tu información de acceso. ¿Mostrar?", + "@showUncensoredLogMessage": {}, + "resetTabs": "Restablecer las pestañas", + "@resetTabs": {}, + "insertedIntoQueue": "Insertado en la cola.", + "@insertedIntoQueue": { + "description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end." + }, + "playNext": "Reproducir la siguiente", + "@playNext": { + "description": "Popup menu item title for inserting an item into the play queue after the currently-playing item." + }, + "noMusicLibrariesTitle": "Sin bibliotecas de música", + "@noMusicLibrariesTitle": { + "description": "Title for message that shows on the views screen when no music libraries could be found." + }, + "refresh": "REFRESCAR", + "@refresh": {}, + "noMusicLibrariesBody": "Finamp no ha podido encontrar ninguna biblioteca de música. Asegúrate de que tu servidor Jellyfin contiene al menos una biblioteca con el tipo de \"Música\".", + "@noMusicLibrariesBody": {}, + "deleteDownloadsConfirmButtonText": "Borrar", + "@deleteDownloadsConfirmButtonText": { + "description": "Shown in the confirmation dialog for deleting downloaded media from the local device." + }, + "deleteDownloadsPrompt": "¿Estás seguro de que quieres eliminar la {itemType, select, album{album} playlist{playlist} artist{artist} genre{género} track{song} other{}} '{itemName}' de este dispositivo?", + "@deleteDownloadsPrompt": { + "placeholders": { + "itemName": { + "type": "String", + "example": "Abandon Ship" + }, + "itemType": { + "type": "String", + "example": "album" + } + }, + "description": "Confirmation prompt shown before deleting downloaded media from the local device, destructive action, doesn't affect the media on the server." + }, + "deleteDownloadsAbortButtonText": "Cancelar", + "@deleteDownloadsAbortButtonText": {}, + "showFastScroller": "Mostrar desplazamiento rápido", + "@showFastScroller": {}, + "syncDownloadedPlaylists": "Sincroniza las listas de reproducción descargadas", + "@syncDownloadedPlaylists": {}, + "swipeInsertQueueNextSubtitle": "Permite insertar una canción como siguiente elemento en la cola cuando se desliza en la lista de reproducción en lugar de añadirla al final.", + "@swipeInsertQueueNextSubtitle": {}, + "interactions": "Interacciones", + "@interactions": {}, + "swipeInsertQueueNext": "Reproducir la siguiente canción al deslizarla", + "@swipeInsertQueueNext": {}, + "redesignBeta": "Prueba la versión Beta", + "@redesignBeta": {}, + "playbackOrderShuffledTooltip": "Mezclando. Toca para alternar.", + "@playbackOrderShuffledTooltip": {}, + "playbackOrderLinearTooltip": "Reproducción en orden. Toca para alternar.", + "@playbackOrderLinearTooltip": {}, + "loopModeAllTooltip": "Repitiendo todo. Toca para alternar.", + "@loopModeAllTooltip": {}, + "loopModeOneTooltip": "En bucle. Toca para alternar.", + "@loopModeOneTooltip": {}, + "loopModeNoneTooltip": "No se reproduce en bucle. Toca para alternar.", + "@loopModeNoneTooltip": {}, + "skipToPrevious": "Saltar a la canción anterior", + "@skipToPrevious": {}, + "skipToNext": "Saltar a la siguiente canción", + "@skipToNext": {}, + "togglePlayback": "Alternar reproducción", + "@togglePlayback": {}, + "playArtist": "Reproducir todos los álbumes de {artist}", + "@playArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "shuffleArtist": "Reproducir aleatoriamente todos los álbumes de {artist}", + "@shuffleArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "downloadArtist": "Descargar todos los álbumes de {artist}", + "@downloadArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "deleteFromDevice": "Eliminar del dispositivo", + "@deleteFromDevice": {}, + "download": "Descargar", + "@download": {}, + "sync": "Sincronizar con el servidor", + "@sync": {}, + "about": "Acerca de Finamp", + "@about": {} +} diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb new file mode 100644 index 0000000..2671f86 --- /dev/null +++ b/lib/l10n/app_et.arb @@ -0,0 +1,475 @@ +{ + "serverUrl": "Serveri URL", + "@serverUrl": {}, + "emptyServerUrl": "Serveri URL ei tohi olla tühi", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "urlStartWithHttps": "URL alguses peab olema http:// või https://", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "urlTrailingSlash": "URL ei tohi lõppeda kaldkriipsuga", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "username": "Kasutajanimi", + "@username": {}, + "password": "Parool", + "@password": {}, + "internalExternalIpExplanation": "Kui soovid oma Jellyfini serverile kaugjuurdepääsu saada, pead kasutama oma välist IP-d.\n\nKui server kasutab HTTP-porti (80/443), ei pea porti määrama. See on tõenäoliselt nii, kui server on pöördpuhverserveri taga.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "logs": "Logid", + "@logs": {}, + "next": "Järgmine", + "@next": {}, + "selectMusicLibraries": "Vali muusikakogu", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "couldNotFindLibraries": "Ühtki muusikakogu ei leitud.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "unknownName": "Tundmatu nimi", + "@unknownName": {}, + "songs": "Lood", + "@songs": {}, + "albums": "Albumid", + "@albums": {}, + "artists": "Esitajad", + "@artists": {}, + "genres": "Žanrid", + "@genres": {}, + "playlists": "Esitusloendid", + "@playlists": {}, + "startMix": "Käivita miks", + "@startMix": {}, + "startMixNoSongsArtist": "Vajuta pikalt esitajale, et lisada või eemaldada ta enne miksi koostamise alustamist koostajast", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "startMixNoSongsAlbum": "Vajuta pikalt albumile, et lisada või eemaldada ta enne miksi koostamise alustamist koostajast", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "sortOrder": "Sortimisjärjestus", + "@sortOrder": {}, + "clear": "Puhasta", + "@clear": {}, + "favourites": "Lemmikud", + "@favourites": {}, + "finamp": "Finamp", + "@finamp": {}, + "downloads": "Allalaadimised", + "@downloads": {}, + "settings": "Seaded", + "@settings": {}, + "offlineMode": "Võrguühenduseta režiim", + "@offlineMode": {}, + "album": "Album", + "@album": {}, + "artist": "Esitaja", + "@artist": {}, + "sortBy": "Sordi", + "@sortBy": {}, + "albumArtist": "Albumi esitaja", + "@albumArtist": {}, + "communityRating": "Kogukonna hinnang", + "@communityRating": {}, + "criticRating": "Kriitikute hinnang", + "@criticRating": {}, + "dateAdded": "Lisamise kuupäev", + "@dateAdded": {}, + "datePlayed": "Esituse kuupäev", + "@datePlayed": {}, + "playCount": "Esituste arv", + "@playCount": {}, + "productionYear": "Tootmisaasta", + "@productionYear": {}, + "name": "Nimi", + "@name": {}, + "random": "Juhuslik", + "@random": {}, + "revenue": "Tulud", + "@revenue": {}, + "runtime": "Kestus", + "@runtime": {}, + "downloadMissingImages": "Laadi alla puuduvad pildid", + "@downloadMissingImages": {}, + "downloadErrors": "Allalaadimisvead", + "@downloadErrors": {}, + "budget": "Eelarve", + "@budget": {}, + "premiereDate": "Avaldamise kuupäev", + "@premiereDate": {}, + "downloadedMissingImages": "{count,plural, =0{Puuduvaid pilte ei leitud} =1{Allalaetud {count} puuduv pilt} other{Allalaetud {count} puuduvat pilti}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadCount": "{count,plural, =1{{count} allalaadimine} other{{count} allalaadimist}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsCount": "{count,plural,=1{{count} üksus} other{{count} üksust}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlComplete": "{count} tehtud", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlFailed": "{count} nurjus", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "dlEnqueued": "{count} järjekorras", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "{count} käsil", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "Allalaadimisvead", + "@downloadErrorsTitle": {}, + "noErrors": "Pole vigu!", + "@noErrors": {}, + "errorScreenError": "Vigade loendi saamisel tekkis viga! Siinkohal peaksid ilmselt lihtsalt looma probleemi GitHubis ja kustutama rakenduse andmed", + "@errorScreenError": {}, + "failedToGetSongFromDownloadId": "Allalaadimise ID-st laulu hankimine nurjus", + "@failedToGetSongFromDownloadId": {}, + "error": "Viga", + "@error": {}, + "discNumber": "Plaat {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "playButtonLabel": "MÄNGI", + "@playButtonLabel": {}, + "shuffleButtonLabel": "SEGA", + "@shuffleButtonLabel": {}, + "songCount": "{count,plural,=1{{count} lugu} other{{count} lugu}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "editPlaylistNameTooltip": "Muuda esitusloendi nime", + "@editPlaylistNameTooltip": {}, + "editPlaylistNameTitle": "Muuda esitusloendi nime", + "@editPlaylistNameTitle": {}, + "required": "Nõutav", + "@required": {}, + "updateButtonLabel": "UUENDA", + "@updateButtonLabel": {}, + "playlistNameUpdated": "Esitusloendi nimi uuendatud.", + "@playlistNameUpdated": {}, + "favourite": "Lemmik", + "@favourite": {}, + "downloadsDeleted": "Allalaadimised kustutati.", + "@downloadsDeleted": {}, + "addDownloads": "Lisa allalaadimised", + "@addDownloads": {}, + "location": "Asukoht", + "@location": {}, + "downloadsAdded": "Allalaadimised lisatud.", + "@downloadsAdded": {}, + "addButtonLabel": "LISA", + "@addButtonLabel": {}, + "shareLogs": "Jaga logisid", + "@shareLogs": {}, + "logsCopied": "Logid kopeeriti.", + "@logsCopied": {}, + "message": "Sõnum", + "@message": {}, + "applicationLegalese": "Litsentsitud Mozilla avaliku litsentsiga 2.0. Lähtekood on saadaval aadressil:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "transcoding": "Transkoodimine", + "@transcoding": {}, + "downloadLocations": "Allalaadimiste asukohad", + "@downloadLocations": {}, + "audioService": "Heliteenus", + "@audioService": {}, + "layoutAndTheme": "Paigutus ja teema", + "@layoutAndTheme": {}, + "notAvailableInOfflineMode": "Pole võrguühenduseta režiimis saadaval", + "@notAvailableInOfflineMode": {}, + "logOut": "Logi välja", + "@logOut": {}, + "downloadedSongsWillNotBeDeleted": "Allalaaditud lugusid ei kustutata", + "@downloadedSongsWillNotBeDeleted": {}, + "areYouSure": "Kas oled kindel?", + "@areYouSure": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin kasutab transkodeerimiseks AAC-d", + "@jellyfinUsesAACForTranscoding": {}, + "enableTranscoding": "Luba transkodeerimine", + "@enableTranscoding": {}, + "enableTranscodingSubtitle": "Transkodeerib muusikavooge serveri poolel.", + "@enableTranscodingSubtitle": {}, + "customLocation": "Kohandatud asukoht", + "@customLocation": {}, + "appDirectory": "Rakenduse kataloog", + "@appDirectory": {}, + "addDownloadLocation": "Lisa allalaadimise asukoht", + "@addDownloadLocation": {}, + "selectDirectory": "Vali kataloog", + "@selectDirectory": {}, + "unknownError": "Tundmatu viga", + "@unknownError": {}, + "enterLowPriorityStateOnPause": "Kasuta pausil madala prioriteediga olekut", + "@enterLowPriorityStateOnPause": {}, + "enterLowPriorityStateOnPauseSubtitle": "Laseb pausi ajal märguande ära pühkida. Võimaldab Androidil ka teenuse pausi ajal lõpetada.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "shuffleAllSongCountSubtitle": "Laaditavate lugude hulk, kui kasutada nuppu 'Sega kõik lood'.", + "@shuffleAllSongCountSubtitle": {}, + "viewType": "Vaate tüüp", + "@viewType": {}, + "viewTypeSubtitle": "Muusikaekraani vaate tüüp", + "@viewTypeSubtitle": {}, + "landscape": "Lai pilt", + "@landscape": {}, + "pathReturnSlashErrorMessage": "Radu, mis tagastavad \"/\", ei saa kasutada", + "@pathReturnSlashErrorMessage": {}, + "showTextOnGridView": "Kuva teksti ruudustikuvaates", + "@showTextOnGridView": {}, + "showTextOnGridViewSubtitle": "Kas kuvada teksti (pealkiri, esitaja jne) muusikaekraani ruudustikul või mitte.", + "@showTextOnGridViewSubtitle": {}, + "showCoverAsPlayerBackground": "Kuva hägustatud kaanepilt mängija taustana", + "@showCoverAsPlayerBackground": {}, + "showCoverAsPlayerBackgroundSubtitle": "Kas kasutada mängija ekraanil taustaks hägustatud kaanekujundust või mitte.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Kas näidata lugude esitajaid albumiekraanil, kui need ei erine albumi esitajatest.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "theme": "Teema", + "@theme": {}, + "system": "Süsteem", + "@system": {}, + "dark": "Tume", + "@dark": {}, + "tabs": "Vahekaardid", + "@tabs": {}, + "cancelSleepTimer": "Kas tühistada unetaimer?", + "@cancelSleepTimer": {}, + "yesButtonLabel": "JAH", + "@yesButtonLabel": {}, + "noButtonLabel": "EI", + "@noButtonLabel": {}, + "setSleepTimer": "Määra unetaimer", + "@setSleepTimer": {}, + "minutes": "minutit", + "@minutes": {}, + "invalidNumber": "Sobimatu number", + "@invalidNumber": {}, + "sleepTimerTooltip": "Unetaimer", + "@sleepTimerTooltip": {}, + "addToPlaylistTooltip": "Lisa esitusloendisse", + "@addToPlaylistTooltip": {}, + "addToPlaylistTitle": "Lisa esitusloendisse", + "@addToPlaylistTitle": {}, + "newPlaylist": "Uus esitusloend", + "@newPlaylist": {}, + "createButtonLabel": "LOO", + "@createButtonLabel": {}, + "playlistCreated": "Esitusloend on loodud.", + "@playlistCreated": {}, + "noAlbum": "Albumit pole", + "@noAlbum": {}, + "noItem": "Üksust pole", + "@noItem": {}, + "noArtist": "Esitajat pole", + "@noArtist": {}, + "unknownArtist": "Tundmatu esitaja", + "@unknownArtist": {}, + "streaming": "VOOGESITUS", + "@streaming": {}, + "downloaded": "ALLALAADITUD", + "@downloaded": {}, + "transcode": "TRANSKOODI", + "@transcode": {}, + "direct": "OTSE", + "@direct": {}, + "statusError": "OLEKUVIGA", + "@statusError": {}, + "queue": "Järjekord", + "@queue": {}, + "addToQueue": "Lisa järjekorda", + "@addToQueue": {}, + "replaceQueue": "Asenda järjekord", + "@replaceQueue": {}, + "removeFavourite": "Eemalda lemmik", + "@removeFavourite": {}, + "addFavourite": "Lisa lemmik", + "@addFavourite": {}, + "addedToQueue": "Lisatud järjekorda.", + "@addedToQueue": {}, + "queueReplaced": "Järjekord asendatud.", + "@queueReplaced": {}, + "startingInstantMix": "Kiirmiksi käivitamine.", + "@startingInstantMix": {}, + "instantMix": "Kiirmiks", + "@instantMix": {}, + "anErrorHasOccured": "Ilmnes tõrge.", + "@anErrorHasOccured": {}, + "responseError": "{error} Olekukood {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "responseError401": "{error} Olekukood {statusCode}. Tõenäoliselt tähendab see, et kasutasid vale kasutajanime/parooli või klient pole enam autenditud.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "removeFromMix": "Eemalda miksist", + "@removeFromMix": {}, + "addToMix": "Lisa miksi", + "@addToMix": {}, + "redownloadedItems": "{count,plural, =0{Allalaadimine pole vajalik.} =1{Uuesti laeti alla {count} üksus} other{Uuesti laeti alla {count} üksust}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "goToAlbum": "Ava album", + "@goToAlbum": {}, + "music": "Muusika", + "@music": {}, + "shuffleAll": "Sega kõik", + "@shuffleAll": {}, + "directoryMustBeEmpty": "Kataloog peab olema tühi", + "@directoryMustBeEmpty": {}, + "bitrate": "Bitikiirus", + "@bitrate": {}, + "bitrateSubtitle": "Suurem bitikiirus annab kvaliteetsema heli, kuid see nõuab suuremat ribalaiust.", + "@bitrateSubtitle": {}, + "customLocationsBuggy": "Kohandatud asukohad on äärmiselt vigased, kuna on probleeme õigustega. Ma mõtlen, kuidas seda parandada, kuid praegu ma ei soovitaks neid kasutada.", + "@customLocationsBuggy": {}, + "list": "Loend", + "@list": {}, + "shuffleAllSongCount": "'Sega kõik lood' arv", + "@shuffleAllSongCount": {}, + "portrait": "Portree", + "@portrait": {}, + "grid": "Ruudustik", + "@grid": {}, + "gridCrossAxisCount": "{value} Ruudustiku risttelgede arv", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "gridCrossAxisCountSubtitle": "Ruudustiku plaatide arv, mida kasutatakse rea kohta, kui {value}.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "hideSongArtistsIfSameAsAlbumArtists": "Peida laulu esitajad, kui need on samad kui albumi esitajad", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "light": "Hele", + "@light": {}, + "startupError": "Rakenduse käivitamisel läks midagi valesti. Viga oli: {error}\n\nPalun loo probleem github.com/UnicornsOnLSD/finamp koos ekraanipildiga sellest leheküljest. Kui see probleem püsib, võiks rakenduse lähtestamiseks kustutada rakenduse andmed.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "downloadedImagesCount": "{count,plural,=1{{count} pilt} other{{count} pilti}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "disableGesture": "Keela žestid", + "@disableGesture": {}, + "bufferDuration": "Puhvri kestus", + "@bufferDuration": {}, + "disableGestureSubtitle": "Kas keelata žestid.", + "@disableGestureSubtitle": {}, + "bufferDurationSubtitle": "Kui palju mängija peaks puhverdama sekundites. Nõuab taaskäivitamist.", + "@bufferDurationSubtitle": {} +} diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb new file mode 100644 index 0000000..8d4250d --- /dev/null +++ b/lib/l10n/app_fi.arb @@ -0,0 +1,594 @@ +{ + "unknownError": "Tuntematon virhe", + "@unknownError": {}, + "removedFromPlaylist": "Poistettu soittolistalta.", + "@removedFromPlaylist": {}, + "addFavourite": "Lisää suosikki", + "@addFavourite": {}, + "goToAlbum": "Mene albumiin", + "@goToAlbum": {}, + "downloads": "Lataukset", + "@downloads": {}, + "settings": "Asetukset", + "@settings": {}, + "shareLogs": "Jaa lokit", + "@shareLogs": {}, + "removeFavourite": "Poista suosikki", + "@removeFavourite": {}, + "playNext": "Toista seuraava", + "@playNext": { + "description": "Popup menu item title for inserting an item into the play queue after the currently-playing item." + }, + "confirm": "Vahvista", + "@confirm": {}, + "language": "Kieli", + "@language": {}, + "refresh": "VIRKISTÄ", + "@refresh": {}, + "serverUrl": "Palvelimen URL", + "@serverUrl": {}, + "emptyServerUrl": "Palvelimen URL ei voi olla tyhjä", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "urlStartWithHttps": "URL pitää alkaa http:// tai https://", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "urlTrailingSlash": "URL ei saa päättyä vinoviivaan", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "username": "Käyttäjätunnus", + "@username": {}, + "password": "Salasana", + "@password": {}, + "logs": "Lokit", + "@logs": {}, + "next": "Seuraava", + "@next": {}, + "selectMusicLibraries": "Valitse musiikkikirjastot", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "couldNotFindLibraries": "Yhtään kirjastoa ei löytynyt.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "unknownName": "Tuntematon Nimi", + "@unknownName": {}, + "songs": "Kappaleet", + "@songs": {}, + "albums": "Albumit", + "@albums": {}, + "artists": "Artistit", + "@artists": {}, + "genres": "Tyylilajit", + "@genres": {}, + "playlists": "Soittolistat", + "@playlists": {}, + "music": "Musiikki", + "@music": {}, + "clear": "Tyhjennä", + "@clear": {}, + "favourites": "Suosikit", + "@favourites": {}, + "offlineMode": "Offline tila", + "@offlineMode": {}, + "finamp": "Finamp", + "@finamp": {}, + "sortOrder": "Lajittelujärjestys", + "@sortOrder": {}, + "album": "Albumi", + "@album": {}, + "albumArtist": "Albumin artisti", + "@albumArtist": {}, + "artist": "Artisti", + "@artist": {}, + "name": "Nimi", + "@name": {}, + "random": "Satunnainen", + "@random": {}, + "criticRating": "Kriitikoiden arvostelu", + "@criticRating": {}, + "downloadMissingImages": "Lataa puuttuvat kuvat", + "@downloadMissingImages": {}, + "syncDownloadedPlaylists": "Synkronoi ladatut soittolistat", + "@syncDownloadedPlaylists": {}, + "downloadErrors": "Latauksen virheet", + "@downloadErrors": {}, + "dlFailed": "{count} epäonnistui", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlComplete": "{count} valmistunut", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "{count} käynnissä", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "Latauksen virheet", + "@downloadErrorsTitle": {}, + "noErrors": "Ei virheitä!", + "@noErrors": {}, + "deleteDownloadsConfirmButtonText": "Poista", + "@deleteDownloadsConfirmButtonText": { + "description": "Shown in the confirmation dialog for deleting downloaded media from the local device." + }, + "deleteDownloadsAbortButtonText": "Peruuta", + "@deleteDownloadsAbortButtonText": {}, + "error": "Virhe", + "@error": {}, + "playButtonLabel": "TOISTA", + "@playButtonLabel": {}, + "shuffleButtonLabel": "SEKOITA", + "@shuffleButtonLabel": {}, + "discNumber": "Levy {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "editPlaylistNameTitle": "Muokkaa soittolistan nimeä", + "@editPlaylistNameTitle": {}, + "editPlaylistNameTooltip": "Muokkaa soittolistan nimeä", + "@editPlaylistNameTooltip": {}, + "playlistNameUpdated": "Soittolistan nimi päivitetty.", + "@playlistNameUpdated": {}, + "favourite": "Suosikki", + "@favourite": {}, + "addDownloads": "Lisää lataukset", + "@addDownloads": {}, + "updateButtonLabel": "PÄIVITÄ", + "@updateButtonLabel": {}, + "downloadsDeleted": "Lataukset poistettu.", + "@downloadsDeleted": {}, + "location": "Sijainti", + "@location": {}, + "downloadsAdded": "Lataukset lisätty.", + "@downloadsAdded": {}, + "addButtonLabel": "LISÄÄ", + "@addButtonLabel": {}, + "logsCopied": "Lokit kopioitu.", + "@logsCopied": {}, + "message": "Viesti", + "@message": {}, + "downloadLocations": "Latauksen sijainnit", + "@downloadLocations": {}, + "transcoding": "Transkoodaus", + "@transcoding": {}, + "notAvailableInOfflineMode": "Ei saatavilla offline tilassa", + "@notAvailableInOfflineMode": {}, + "areYouSure": "Oletko varma?", + "@areYouSure": {}, + "logOut": "Kirjaudu ulos", + "@logOut": {}, + "downloadedSongsWillNotBeDeleted": "Ladattuja kappaleita ei poisteta", + "@downloadedSongsWillNotBeDeleted": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin käyttää AAC:tä transkoodaukseen", + "@jellyfinUsesAACForTranscoding": {}, + "enableTranscoding": "Ota transkoodaus käyttöön", + "@enableTranscoding": {}, + "enableTranscodingSubtitle": "Transkoodaa musiikin suoratoiston palvelimen päässä.", + "@enableTranscodingSubtitle": {}, + "shuffleAll": "Sekoita kaikki", + "@shuffleAll": {}, + "budget": "Budjetti", + "@budget": {}, + "communityRating": "Yhteisön arvostelu", + "@communityRating": {}, + "dateAdded": "Lisäämisen päivämäärä", + "@dateAdded": {}, + "datePlayed": "Toiston päivämäärä", + "@datePlayed": {}, + "playCount": "Toistolaskuri", + "@playCount": {}, + "productionYear": "Tuotantovuosi", + "@productionYear": {}, + "revenue": "Tulot", + "@revenue": {}, + "runtime": "Kesto", + "@runtime": {}, + "dlEnqueued": "{count} Jonossa", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "required": "Pakollinen", + "@required": {}, + "layoutAndTheme": "Asettelu ja Teema", + "@layoutAndTheme": {}, + "viewType": "Näkymän Tyyppi", + "@viewType": {}, + "list": "Lista", + "@list": {}, + "grid": "Ruudukko", + "@grid": {}, + "portrait": "Pysty", + "@portrait": {}, + "landscape": "Vaaka", + "@landscape": {}, + "showTextOnGridView": "Näytä teksti ruudukkonäkymässä", + "@showTextOnGridView": {}, + "theme": "Teema", + "@theme": {}, + "system": "Järjestelmä", + "@system": {}, + "light": "Vaalea", + "@light": {}, + "tabs": "Välilehdet", + "@tabs": {}, + "cancelSleepTimer": "Peruuta uniajastin?", + "@cancelSleepTimer": {}, + "yesButtonLabel": "KYLLÄ", + "@yesButtonLabel": {}, + "setSleepTimer": "Aseta uniajastin", + "@setSleepTimer": {}, + "minutes": "Minuutit", + "@minutes": {}, + "sleepTimerTooltip": "Uniajastin", + "@sleepTimerTooltip": {}, + "addToPlaylistTooltip": "Lisää soittolistalle", + "@addToPlaylistTooltip": {}, + "removeFromPlaylistTooltip": "Poista soittolistalta", + "@removeFromPlaylistTooltip": {}, + "removeFromPlaylistTitle": "Poista soittolistalta", + "@removeFromPlaylistTitle": {}, + "direct": "SUORA", + "@direct": {}, + "queue": "Jono", + "@queue": {}, + "addedToQueue": "Lisätty jonoon.", + "@addedToQueue": { + "description": "Snackbar message that shows when the user successfully adds items to the end of the play queue." + }, + "anErrorHasOccured": "Tapahtui virhe.", + "@anErrorHasOccured": {}, + "bufferDuration": "Puskurin kesto", + "@bufferDuration": {}, + "dark": "Tumma", + "@dark": {}, + "noButtonLabel": "EI", + "@noButtonLabel": {}, + "newPlaylist": "Uusi soittolista", + "@newPlaylist": {}, + "createButtonLabel": "LUO", + "@createButtonLabel": {}, + "noAlbum": "Ei albumia", + "@noAlbum": {}, + "addToPlaylistTitle": "Lisää soittolistalle", + "@addToPlaylistTitle": {}, + "playlistCreated": "Soittolista on luotu.", + "@playlistCreated": {}, + "noArtist": "Ei Artistia", + "@noArtist": {}, + "unknownArtist": "Tuntematon artisti", + "@unknownArtist": {}, + "downloaded": "LADATTU", + "@downloaded": {}, + "addToQueue": "Lisää jonoon", + "@addToQueue": { + "description": "Popup menu item title for adding an item to the end of the play queue." + }, + "resetTabs": "Nollaa välilehdet", + "@resetTabs": {}, + "noMusicLibrariesTitle": "Ei musiikkikirjastoja", + "@noMusicLibrariesTitle": { + "description": "Title for message that shows on the views screen when no music libraries could be found." + }, + "downloadedItemsCount": "{count,plural,=1{{count} kohde} other{{count} kohteet}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadCount": "{count,plural, =1{{count} lataus} other{{count} lataukset}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "downloadedImagesCount": "{count,plural,=1{{count} kuva} other{{count} kuvat}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "showCoverAsPlayerBackground": "Näytä sumennettu kansikuva soittimen taustakuvana", + "@showCoverAsPlayerBackground": {}, + "hideSongArtistsIfSameAsAlbumArtists": "Piilota kappaleen artistit, jos samat kuin albumin artistit", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "insertedIntoQueue": "Asetettu jonoon.", + "@insertedIntoQueue": { + "description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end." + }, + "responseError": "{error} Tilakoodi {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "showUncensoredLogMessage": "Tämä loki sisältää kirjautumistietosi. Näytä?", + "@showUncensoredLogMessage": {}, + "directoryMustBeEmpty": "Hakemiston pitää olla tyhjä", + "@directoryMustBeEmpty": {}, + "selectDirectory": "Valitse hakemisto", + "@selectDirectory": {}, + "songCount": "{count,plural,=1{{count} Kappale} other{{count} Kappaleita}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "sortBy": "Järjestä", + "@sortBy": {}, + "premiereDate": "Ensiesityspäivä", + "@premiereDate": {}, + "interactions": "Vuorovaikutukset", + "@interactions": {}, + "audioService": "Äänipalvelu", + "@audioService": {}, + "bitrate": "Bitrate", + "@bitrate": {}, + "bitrateSubtitle": "Suurempi bitrate antaa laadukkaamman äänen, mutta sen käyttämä kaistanleveys on suurempi.", + "@bitrateSubtitle": {}, + "customLocation": "Mukautettu sijainti", + "@customLocation": {}, + "appDirectory": "Sovelluksen hakemisto", + "@appDirectory": {}, + "addDownloadLocation": "Lisää latauksen hakemisto", + "@addDownloadLocation": {}, + "pathReturnSlashErrorMessage": "Polkuja jotka palauttavat \"/\" ei voi käyttää", + "@pathReturnSlashErrorMessage": {}, + "disableGesture": "Poista eleet käytöstä", + "@disableGesture": {}, + "disableGestureSubtitle": "Poistaa eleet käytöstä.", + "@disableGestureSubtitle": {}, + "invalidNumber": "Virheellinen numero", + "@invalidNumber": {}, + "noItem": "Ei kohdetta", + "@noItem": {}, + "streaming": "SUORATOISTAA", + "@streaming": {}, + "transcode": "TRANSKOODI", + "@transcode": {}, + "noMusicLibrariesBody": "Finamp ei löytänyt musiikkikirjastoja. Varmista, että Jellyfin-palvelimellasi on vähintään yksi kirjasto, jonka sisältötyypiksi on asetettu \"Musiikki\".", + "@noMusicLibrariesBody": {}, + "instantMix": "Välitön Sekoitus", + "@instantMix": {}, + "replaceQueue": "Korvaa Jono", + "@replaceQueue": {}, + "queueReplaced": "Jono korvattu.", + "@queueReplaced": {}, + "startingInstantMix": "Käynnistetään välitön sekoitus.", + "@startingInstantMix": {}, + "addToMix": "Lisää Sekoitukseen", + "@addToMix": {}, + "removeFromMix": "Poista Sekoituksesta", + "@removeFromMix": {}, + "bufferDurationSubtitle": "Kuinka paljon soittimen pitäisi puskuroida, sekunteina. Vaatii uudelleenkäynnistyksen.", + "@bufferDurationSubtitle": {}, + "redesignBeta": "Kokeile Betaa", + "@redesignBeta": {}, + "swipeInsertQueueNext": "Toista Pyyhkäisty Kappale Seuraavaksi", + "@swipeInsertQueueNext": {}, + "startMix": "Aloita sekoitus", + "@startMix": {}, + "failedToGetSongFromDownloadId": "Kappaleen nouto lataus ID:stä epäonnistui", + "@failedToGetSongFromDownloadId": {}, + "stackTrace": "Pinon jäljitys", + "@stackTrace": {}, + "applicationLegalese": "Lisensoitu Mozilla Public License 2.0 -lisenssillä. Lähdekoodi saatavilla osoitteessa:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "customLocationsBuggy": "Mukautetut sijainnit ovat erittäin bugisia käyttöoikeusongelmien vuoksi. Mietin tapoja korjata tämä, mutta toistaiseksi en suosittele niiden käyttöä.", + "@customLocationsBuggy": {}, + "enterLowPriorityStateOnPauseSubtitle": "Sallii ilmoituksen pyyhkäisemisen pois, kun toisto on pysäytetty. Antaa myös Androidin lopettaa palvelun, kun toisto on keskeytetty.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "startupError": "Jokin meni pieleen sovelluksen käynnistyksen aikana. Virhe oli: {error}\n\nOle hyvä ja luo virheilmoitus osoitteessa github.com/UnicornsOnLSD/finamp, jossa on kuvakaappaus tästä sivusta. Jos ongelma jatkuu, voit tyhjentää sovelluksen tiedot nollataksesi sovelluksen.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "internalExternalIpExplanation": "Jos haluat käyttää Jellyfin-palvelintasi etänä, sinun on käytettävä ulkoista IP-osoitettasi.\n\nJos palvelimesi käyttää HTTP-porttia (80/443), sinun ei tarvitse määrittää porttia. Näin on todennäköisesti, jos palvelimesi on reverse proxyn takana.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "startMixNoSongsAlbum": "Paina albumia pitkään lisätäksesi tai poistaaksesi sen miksaukseen ennen miksauksen aloittamista", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "downloadedMissingImages": "{count,plural, =0{Puuttuvia kuvia ei löytynyt} =1{Ladattu {count} puuttuvaa kuvaa} other{ladattu {count} puuttuvia kuvia}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "errorScreenError": "Virhe tapahtui virheiden luettelon hakemisessa! Tässä vaiheessa sinun pitäisi luultavasti vain luoda virheilmoitus GitHubiin ja poistaa sovelluksen tiedot", + "@errorScreenError": {}, + "deleteDownloadsPrompt": "Oletko varma, että haluat poistaa {itemType, select, album{album} playlist{playlist} artist{artist} genre{genre} track{song} other{}} '{itemName}' tästä laitteesta?", + "@deleteDownloadsPrompt": { + "placeholders": { + "itemName": { + "type": "String", + "example": "Abandon Ship" + }, + "itemType": { + "type": "String", + "example": "album" + } + }, + "description": "Confirmation prompt shown before deleting downloaded media from the local device, destructive action, doesn't affect the media on the server." + }, + "enterLowPriorityStateOnPause": "Siirtyminen matalan prioriteetin tilaan tauon aikana", + "@enterLowPriorityStateOnPause": {}, + "shuffleAllSongCount": "Kaikkien sekoitettujen kappaleiden määrä", + "@shuffleAllSongCount": {}, + "shuffleAllSongCountSubtitle": "Ladattavien kappaleiden määrä, kun käytät sekoita kaikki kappaleet painiketta.", + "@shuffleAllSongCountSubtitle": {}, + "gridCrossAxisCount": "{value} Ruudukon poikittaisakselien lukumäärä", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "gridCrossAxisCountSubtitle": "Rivikohtaisesti käytettävien ruudukkotiilien määrä, kun {value}.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "showTextOnGridViewSubtitle": "Näytetäänkö teksti (nimi, artisti jne.) ruudukon musiikkinäytöllä vai ei.", + "@showTextOnGridViewSubtitle": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Näytetäänkö kappaleiden artistit albumin näytöllä, jos ne eivät poikkea albumin artisteista.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "showFastScroller": "Näytä nopea vieritin", + "@showFastScroller": {}, + "statusError": "TILAVIRHE", + "@statusError": {}, + "responseError401": "{error} Tilakoodi {statusCode}. Tämä tarkoittaa todennäköisesti, että olet käyttänyt väärää käyttäjätunnusta/salasanaa tai että sovellus ei ole enää kirjautuneena sisään.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "redownloadedItems": "{count,plural, =0{Ei tarvitse ladata uudelleen.} =1{Uudelleenladattu {count} kohde} other{Uudelleenladatut {count} kohteet}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "swipeInsertQueueNextSubtitle": "Mahdollistaa kappaleen lisäämisen jonon seuraavaksi kohteeksi, kun sitä pyyhkäistään kappaleiden luettelossa sen sijaan, että se liitettäisiin loppuun.", + "@swipeInsertQueueNextSubtitle": {}, + "startMixNoSongsArtist": "Paina pitkään artistia lisätäksesi tai poistaaksesi sen miksaukseen ennen miksauksen aloittamista", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "viewTypeSubtitle": "Musiikkinäytön näkymätyyppi", + "@viewTypeSubtitle": {}, + "showCoverAsPlayerBackgroundSubtitle": "Käytetäänkö sumeaa kansikuvitusta taustana soittimen näytöllä vai ei.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "downloadArtist": "Lataa kaikki {artist}:n albumit", + "@downloadArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "playbackOrderShuffledTooltip": "Sekoittaminen. Vaihda napauttamalla.", + "@playbackOrderShuffledTooltip": {}, + "playbackOrderLinearTooltip": "Toistaminen järjestyksessä. Vaihda napauttamalla.", + "@playbackOrderLinearTooltip": {}, + "skipToPrevious": "Siirry edelliseen kappaleeseen", + "@skipToPrevious": {}, + "skipToNext": "Siirry seuraavaan kappaleeseen", + "@skipToNext": {}, + "togglePlayback": "Toiston vaihtaminen", + "@togglePlayback": {}, + "loopModeAllTooltip": "Toista kaikki. Vaihda napauttamalla.", + "@loopModeAllTooltip": {}, + "loopModeOneTooltip": "Toista yksi. Vaihda napauttamalla.", + "@loopModeOneTooltip": {}, + "loopModeNoneTooltip": "Älä toista. Vaihda napauttamalla.", + "@loopModeNoneTooltip": {}, + "playArtist": "Toista kaikki {artist}:n albumit", + "@playArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "shuffleArtist": "Sekoita kaikki {artist}:n albumit", + "@shuffleArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "deleteFromDevice": "Poista laitteelta", + "@deleteFromDevice": {}, + "download": "Lataa", + "@download": {}, + "sync": "Synkronoi palvelimen kanssa", + "@sync": {}, + "about": "Tietoja Finampista", + "@about": {} +} diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb new file mode 100644 index 0000000..40c88ef --- /dev/null +++ b/lib/l10n/app_fr.arb @@ -0,0 +1,587 @@ +{ + "startupError": "Une erreur est survenue lors du démarrage de l'application. L'erreur est: {error}\n\nPlease create an issue on github.com/UnicornsOnLSD/finamp with a screenshot of this page. If this problem persists you can clear your app data to reset the app.", + "@startupError": { + "description": "Le message d'erreur affiché lorsque le démarrage de l'application échoue.", + "placeholders": { + "error": { + "type": "String", + "example": "Impossible d'accéder aux données de téléchargement" + } + } + }, + "serverUrl": "Adresse du serveur", + "@serverUrl": {}, + "internalExternalIpExplanation": "Si vous souhaitez pouvoir accéder à votre serveur Jellyfin à distance, vous devez utiliser votre adresse IP externe.\n\nSi votre serveur utilise un port HTTP (80/443), vous n'avez pas besoin de spécifier de port. C'est probablement le cas si votre serveur est derrière un reverse proxy.", + "@internalExternalIpExplanation": { + "description": "Informations supplémentaires sur l'IP à utiliser pour l'accès à distance, et sur la nécessité ou non de spécifier un port" + }, + "emptyServerUrl": "Vous devez indiquez l'adresse du serveur.", + "@emptyServerUrl": { + "description": "Cette erreur intervient lorsque l'utilisateur tente de se connecter sans indiquer l'adresse du serveur" + }, + "urlStartWithHttps": "L'adresse doit commencer par http:// ou https://", + "@urlStartWithHttps": { + "description": "Cette erreur intervient lorsque l'utilisateur soumet une adresse qui ne commence pas par http:// ou https:// (par exemple, ftp://0.0.0.0)" + }, + "urlTrailingSlash": "L'adresse ne doit pas inclure de slash final.", + "@urlTrailingSlash": { + "description": "Cette erreur intervient lorsque l'utilisateur soumet une adresse qui se termine par une barre oblique finale (par exemple, http://0.0.0.0/)" + }, + "username": "Nom d'utilisateur", + "@username": {}, + "password": "Mot de passe", + "@password": {}, + "logs": "Logs", + "@logs": {}, + "next": "Prochain", + "@next": {}, + "selectMusicLibraries": "Selectionner des bibliothèques", + "@selectMusicLibraries": { + "description": "Titre de l'écran de sélection de bibliothèque" + }, + "couldNotFindLibraries": "Aucune bibliothèque trouvée", + "@couldNotFindLibraries": { + "description": "Cette erreur intervient lorsque l'utilisateur n'a pas de bibliothèque" + }, + "unknownName": "Nom inconnu", + "@unknownName": {}, + "songs": "Titres", + "@songs": {}, + "albums": "Albums", + "@albums": {}, + "artists": "Artistes", + "@artists": {}, + "genres": "Genres", + "@genres": {}, + "playlists": "Playlists", + "@playlists": {}, + "startMix": "Commencer un Mix", + "@startMix": {}, + "startMixNoSongsArtist": "Appuyer longuement sur un artiste pour l'ajouter ou le retirer du mix avant de commencer", + "@startMixNoSongsArtist": { + "description": "Notification qui intervient quand l'utilisateur lance le mix instantané sans avoir sélectionné des artistes" + }, + "startMixNoSongsAlbum": "Appuyer longuement sur un album pour l'ajouter ou le retirer du mix avant de commencer", + "@startMixNoSongsAlbum": { + "description": "Notification qui intervient quand l'utilisateur lance le mix instantané sans avoir sélectionné des albums" + }, + "music": "Musique", + "@music": {}, + "clear": "Effacer", + "@clear": {}, + "favourites": "Favoris", + "@favourites": {}, + "shuffleAll": "Tout mélanger", + "@shuffleAll": {}, + "finamp": "Finamp", + "@finamp": {}, + "downloads": "Téléchargements", + "@downloads": {}, + "settings": "Réglages", + "@settings": {}, + "offlineMode": "Mode hors-ligne", + "@offlineMode": {}, + "sortOrder": "Ordre de tri", + "@sortOrder": {}, + "sortBy": "Trier par", + "@sortBy": {}, + "album": "Album", + "@album": {}, + "albumArtist": "Artiste de l'album", + "@albumArtist": {}, + "artist": "Artiste", + "@artist": {}, + "budget": "Budget", + "@budget": {}, + "communityRating": "Note de la communauté", + "@communityRating": {}, + "criticRating": "Note de la critique", + "@criticRating": {}, + "dateAdded": "Date d'ajout", + "@dateAdded": {}, + "datePlayed": "Date de lecture", + "@datePlayed": {}, + "playCount": "nombre de lectures", + "@playCount": {}, + "premiereDate": "Date de première", + "@premiereDate": {}, + "productionYear": "Date de production", + "@productionYear": {}, + "name": "Nom", + "@name": {}, + "random": "Aléatoire", + "@random": {}, + "revenue": "Recettes", + "@revenue": {}, + "runtime": "Durée d'exécution", + "@runtime": {}, + "syncDownloadedPlaylists": "Synchroniser les playlists téléchargées", + "@syncDownloadedPlaylists": {}, + "downloadMissingImages": "Télécharger les images manquantes", + "@downloadMissingImages": {}, + "downloadedMissingImages": "{count,plural, =0{Pas d'image manquante} =1{{count} image téléchargée} other{{count} images téléchargées}}", + "@downloadedMissingImages": { + "description": "Message affiché lorsque l'utilisateur télécharge des images manquantes", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrors": "Télécharger les erreurs", + "@downloadErrors": {}, + "downloadCount": "{count,plural, =1{{count} téléchargement} other{{count} téléchargements}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsCount": "{count,plural,=1{{count} élément} other{{count} éléments}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedImagesCount": "{count,plural,=1{{count} image} other{{count} images}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "Sert à fusionner downloadedItemsCount et downloadedImagesCount car les outils intl de Flutter ne supportent pas plusieurs pluriels dans une seule chaîne de caractères. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 téléchargements" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "dlComplete": "{count,plural,=1{{count} complété} other{{count} complétés}}", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlFailed": "{count,plural,=1{{count} échec} other{{count} échecs}}", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlEnqueued": "{count} en attente", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "{count} en cours", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "Erreurs de téléchargement", + "@downloadErrorsTitle": {}, + "noErrors": "Pas d'erreur!", + "@noErrors": {}, + "errorScreenError": "Une erreur est survenue lors de la récupération de la liste des erreurs ! À ce stade, il serait probablement préférable de créer une issue sur GitHub et de supprimer les données de l'application.", + "@errorScreenError": {}, + "failedToGetSongFromDownloadId": "Échec de récupération de la chanson à partir de l'ID de téléchargement", + "@failedToGetSongFromDownloadId": {}, + "deleteDownloadsPrompt": "Êtes vour sûr de vouloir supprimer {itemType, select, album{l'album} playlist{la playlist} artist{l'artiste} genre{le genre} track{le titre} other{}} '{itemName}' de cet appareil ?", + "@deleteDownloadsPrompt": { + "placeholders": { + "itemName": { + "type": "String", + "example": "Abandon Ship" + }, + "itemType": { + "type": "String", + "example": "album" + } + }, + "description": "Invite de confirmation affichée avant de supprimer les médias téléchargés du périphérique local, action destructrice, n'affecte pas les médias sur le serveur." + }, + "deleteDownloadsConfirmButtonText": "Supprimer", + "@deleteDownloadsConfirmButtonText": { + "description": "Affiché dans la boîte de dialogue de confirmation pour la suppression des médias téléchargés du périphérique local." + }, + "deleteDownloadsAbortButtonText": "Annuler", + "error": "Erreur", + "@error": {}, + "discNumber": "Disque {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "playButtonLabel": "LIRE", + "@playButtonLabel": {}, + "shuffleButtonLabel": "ALÉATOIRE", + "@shuffleButtonLabel": {}, + "songCount": "{count,plural,=1{{count} titre} other{{count} titres}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "editPlaylistNameTooltip": "Modifier le titre de la playlist", + "@editPlaylistNameTooltip": {}, + "editPlaylistNameTitle": "Edit Playlist Name", + "@editPlaylistNameTitle": {}, + "required": "Requis", + "@required": {}, + "updateButtonLabel": "METTRE À JOUR", + "@updateButtonLabel": {}, + "playlistNameUpdated": "Titre de la playlist mis à jour.", + "@playlistNameUpdated": {}, + "favourite": "Favoris", + "@favourite": {}, + "downloadsDeleted": "Téléchargements supprimés.", + "@downloadsDeleted": {}, + "addDownloads": "Ajouter les téléchargements", + "@addDownloads": {}, + "location": "Emplacement", + "@location": {}, + "downloadsAdded": "Téléchargements ajoutés.", + "@downloadsAdded": {}, + "addButtonLabel": "AJOUTER", + "@addButtonLabel": {}, + "shareLogs": "Partager les journaux.", + "@shareLogs": {}, + "logsCopied": "Journaux copiés.", + "@logsCopied": {}, + "message": "Message", + "@message": {}, + "stackTrace": "Stack Trace", + "@stackTrace": {}, + "applicationLegalese": "Soumis à la licence Mozilla Public License 2.0. Le code source est disponible sur :\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "transcoding": "Transcodage", + "@transcoding": {}, + "downloadLocations": "Emplacements des téléchargements", + "@downloadLocations": {}, + "audioService": "Service audio", + "@audioService": {}, + "interactions": "Intéractions", + "@interactions": {}, + "layoutAndTheme": "Disposition & Apparence", + "@layoutAndTheme": {}, + "notAvailableInOfflineMode": "Indisponible en mode hors-ligne", + "@notAvailableInOfflineMode": {}, + "logOut": "Déconnexion", + "@logOut": {}, + "downloadedSongsWillNotBeDeleted": "Les chansons téléchargées ne seront pas supprimées", + "@downloadedSongsWillNotBeDeleted": {}, + "areYouSure": "Êtes-vous sûr ?", + "@areYouSure": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin utilise AAC lors du transcodage", + "@jellyfinUsesAACForTranscoding": {}, + "enableTranscoding": "Activer le transcodage", + "@enableTranscoding": {}, + "enableTranscodingSubtitle": "Transcode les flux musicaux côté serveur.", + "@enableTranscodingSubtitle": {}, + "bitrate": "Débit binaire", + "@bitrate": {}, + "bitrateSubtitle": "Un débit binaire plus élevé offre une meilleure qualité audio au prix d'une bande passante plus importante.", + "@bitrateSubtitle": {}, + "customLocation": "Emplacement personnalisé", + "@customLocation": {}, + "appDirectory": "Répertoire de l'application", + "@appDirectory": {}, + "addDownloadLocation": "Ajouter un emplacement de téléchargement", + "@addDownloadLocation": {}, + "selectDirectory": "Sélectionner un répertoire", + "@selectDirectory": {}, + "unknownError": "Erreur inconnue", + "@unknownError": {}, + "pathReturnSlashErrorMessage": "Les chemins qui retournent \"/\" ne peuvent pas être utilisés", + "@pathReturnSlashErrorMessage": {}, + "directoryMustBeEmpty": "Le répertoire doit être vide", + "@directoryMustBeEmpty": {}, + "customLocationsBuggy": "Les emplacements personnalisés sont très bogués en raison de problèmes de permissions. Je réfléchis à des solutions pour corriger cela, mais pour l'instant, je ne recommande pas de les utiliser.", + "@customLocationsBuggy": {}, + "enterLowPriorityStateOnPause": "Faible priorité de l'application lorsque la lecture est en pause", + "@enterLowPriorityStateOnPause": {}, + "enterLowPriorityStateOnPauseSubtitle": "Permet de supprimer la notification lorsque la lecture est en pause. Permet également à Android de fermer le service lorsqu'il est en pause.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "shuffleAllSongCount": "Nombre de chansons à mélanger", + "@shuffleAllSongCount": {}, + "shuffleAllSongCountSubtitle": "Nombre de chansons à charger lors de l'utilisation du bouton 'Tout mélanger'.", + "@shuffleAllSongCountSubtitle": {}, + "viewType": "Disposition de l'affichage", + "@viewType": {}, + "viewTypeSubtitle": "Règle la manière dont les musiques sont diposées", + "@viewTypeSubtitle": {}, + "list": "Liste", + "@list": {}, + "grid": "Grille", + "@grid": {}, + "portrait": "Portrait", + "@portrait": {}, + "landscape": "Paysage", + "@landscape": {}, + "gridCrossAxisCount": "Nombre de tuiles par ligne en orientation {value}", + "@gridCrossAxisCount": { + "description": "Titre de l'élément de liste pour le nombre d'axes croisés de la grille. La valeur sera soit la clé 'portrait', soit la clé 'paysage'.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "gridCrossAxisCountSubtitle": "Le nombre de tuiles à utiliser par rangée en orientation {value} lorsque l'affichage est en mode mosaïque.", + "@gridCrossAxisCountSubtitle": { + "description": "Sous-titre de l'élément de liste pour le nombre d'axes croisés de la grille. La valeur sera soit la clé 'portrait', soit la clé 'paysage'.", + "placeholders": { + "value": { + "type": "String", + "example": "Paysage" + } + } + }, + "showTextOnGridView": "Afficher le texte dans la grille de vue", + "@showTextOnGridView": {}, + "showTextOnGridViewSubtitle": "Afficher ou non le texte (titre, artiste, etc.) sur l'écran de la grille musicale.", + "@showTextOnGridViewSubtitle": {}, + "showCoverAsPlayerBackground": "Afficher la couverture floutée en arrière-plan du lecteur", + "@showCoverAsPlayerBackground": {}, + "showCoverAsPlayerBackgroundSubtitle": "Choisir d'utiliser ou non l'illustration floutée comme arrière-plan sur l'écran du lecteur.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "hideSongArtistsIfSameAsAlbumArtists": "Masquer les artistes des chansons si identiques aux artistes de l'album", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Afficher ou non les artistes des chansons sur l'écran de l'album si différents des artistes de l'album.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "disableGesture": "Désactiver les gestes", + "@disableGesture": {}, + "disableGestureSubtitle": "Indique si les gestes doivent être désactivés.", + "@disableGestureSubtitle": {}, + "showFastScroller": "Afficher le défileur rapide", + "@showFastScroller": {}, + "theme": "Thème", + "@theme": {}, + "system": "Système", + "@system": {}, + "light": "Claire", + "@light": {}, + "dark": "Sombre", + "@dark": {}, + "tabs": "Onglets", + "@tabs": {}, + "cancelSleepTimer": "Annuler le minuteur de sommeil ?", + "@cancelSleepTimer": {}, + "yesButtonLabel": "OUI", + "@yesButtonLabel": {}, + "noButtonLabel": "NON", + "@noButtonLabel": {}, + "setSleepTimer": "Configurer le minuteur de sommeil", + "@setSleepTimer": {}, + "minutes": "Minutes", + "@minutes": {}, + "invalidNumber": "Nombre invalide", + "@invalidNumber": {}, + "sleepTimerTooltip": "Minuteur de sommeil", + "@sleepTimerTooltip": {}, + "addToPlaylistTooltip": "Ajouter à une playlist", + "@addToPlaylistTooltip": {}, + "addToPlaylistTitle": "Ajouter à une playlist", + "@addToPlaylistTitle": {}, + "removeFromPlaylistTooltip": "Retirer de la playlist", + "@removeFromPlaylistTooltip": {}, + "removeFromPlaylistTitle": "Retirer de la playlist", + "@removeFromPlaylistTitle": {}, + "newPlaylist": "Nouvelle Playlist", + "@newPlaylist": {}, + "createButtonLabel": "CRÉER", + "@createButtonLabel": {}, + "playlistCreated": "Playlist créée.", + "@playlistCreated": {}, + "noAlbum": "Aucun album", + "@noAlbum": {}, + "noItem": "Aucun élément", + "@noItem": {}, + "noArtist": "Aucun artiste", + "@noArtist": {}, + "unknownArtist": "Artiste inconnu", + "@unknownArtist": {}, + "streaming": "STREAMING", + "@streaming": {}, + "downloaded": "TÉLÉCHARGÉ", + "@downloaded": {}, + "transcode": "TRANSCODAGE", + "@transcode": {}, + "direct": "DIRECT", + "@direct": {}, + "statusError": "STATUS D'ERREUR", + "@statusError": {}, + "queue": "File d'attente", + "@queue": {}, + "addToQueue": "Ajouter à la file d'attente", + "@addToQueue": { + "description": "Titre de l'élément du menu contextuel pour ajouter un élément à la fin de la file d'attente." + }, + "playNext": "Lire ensuite", + "@playNext": { + "description": "Titre de l'élément du menu contextuel pour insérer un élément dans la file d'attente de lecture après l'élément en cours de lecture." + }, + "replaceQueue": "Remplacer la file d'attente", + "@replaceQueue": {}, + "instantMix": "Mix instantané", + "@instantMix": {}, + "goToAlbum": "Voir l'album", + "@goToAlbum": {}, + "removeFavourite": "Retirer des favoris", + "@removeFavourite": {}, + "addFavourite": "Ajouter aux favoris", + "@addFavourite": {}, + "addedToQueue": "Ajouté à la fin de la file d'attente.", + "@addedToQueue": { + "description": "Notification affichée lorsque l'utilisateur ajoute avec succès des éléments à la fin de la file d'attente de lecture." + }, + "insertedIntoQueue": "Ajouté à la file d'attente.", + "@insertedIntoQueue": { + "description": "Notification affichée lorsque l'utilisateur insère avec succès des éléments dans la file d'attente de lecture à un endroit qui n'est pas nécessairement à la fin." + }, + "queueReplaced": "File d'attente remplacée.", + "@queueReplaced": {}, + "removedFromPlaylist": "Retiré de la playlist.", + "@removedFromPlaylist": {}, + "startingInstantMix": "Mix instantané lancé.", + "@startingInstantMix": {}, + "anErrorHasOccured": "Une erreur s'est produite.", + "@anErrorHasOccured": {}, + "responseError": "{error} Status code {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Interdit" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "responseError401": "{error} Code de statut {statusCode}. Cela signifie probablement que vous avez utilisé un mauvais nom d'utilisateur/mot de passe, ou que votre client n'est plus connecté.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Non autorisé" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "removeFromMix": "Retirer du Mix", + "@removeFromMix": {}, + "addToMix": "Ajouter au Mix", + "@addToMix": {}, + "redownloadedItems": "{count,plural, =0{Aucun re-téléchargement nécessaire} =1{{count} élément re-téléchargé} other{{count} éléments re-téléchargés}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "bufferDuration": "Taille du tampon", + "@bufferDuration": {}, + "bufferDurationSubtitle": "Taille du tampon du lecteur, en secondes. Changer ce paramètre nécessite un redémarrage.", + "@bufferDurationSubtitle": {}, + "language": "Langue", + "confirm": "Confirmé", + "showUncensoredLogMessage": "Ce journal contient vos informations de connexion. Afficher tout de même ?", + "resetTabs": "Réinitialiser les onglets", + "noMusicLibrariesTitle": "Aucune bibliothèque musicale", + "@noMusicLibrariesTitle": { + "description": "Titre du message affiché sur l'écran de vues lorsque aucune bibliothèque musicale n'a été trouvée." + }, + "noMusicLibrariesBody": "Finamp n'a pas trouvé de bibliothèques musicales. Veuillez vous assurer que votre serveur Jellyfin contient au moins une bibliothèque avec le type de contenu défini sur \"Musique\".", + "refresh": "RAFRAÎCHIR", + "swipeInsertQueueNext": "Lire la chanson balayée en prochain", + "@swipeInsertQueueNext": {}, + "swipeInsertQueueNextSubtitle": "Activer l'insertion d'une chanson comme élément suivant dans la file d'attente lorsqu'elle est balayée dans la liste des chansons, au lieu de l'ajouter à la fin.", + "@swipeInsertQueueNextSubtitle": {}, + "redesignBeta": "Essayer la bêta", + "@redesignBeta": {}, + "playbackOrderShuffledTooltip": "Lecture en aléatoire. Touchez pour activer/désactiver.", + "@playbackOrderShuffledTooltip": {}, + "playbackOrderLinearTooltip": "Lecture dans l'ordre. Touchez pour activer/désactiver.", + "@playbackOrderLinearTooltip": {}, + "loopModeAllTooltip": "Lecture en boucle de l'ensemble. Touchez pour activer/désactiver.", + "@loopModeAllTooltip": {}, + "loopModeOneTooltip": "Lecture en boucle d'un seul. Touchez pour activer/désactiver.", + "@loopModeOneTooltip": {}, + "loopModeNoneTooltip": "Ne pas lire en boucle. Touchez pour activer/désactiver.", + "@loopModeNoneTooltip": {}, + "skipToPrevious": "Passer à la chanson précédente", + "@skipToPrevious": {}, + "skipToNext": "Passer à la chanson suivante", + "@skipToNext": {}, + "togglePlayback": "Activer/désactiver la lecture", + "@togglePlayback": {}, + "playArtist": "Lire tous les albums de {artist}", + "@playArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "shuffleArtist": "Lire aléatoirement tous les albums de {artist}", + "@shuffleArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "downloadArtist": "Télécharger tous les albums de {artist}", + "@downloadArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "deleteFromDevice": "Supprimer de l'appareil", + "@deleteFromDevice": {}, + "download": "Télécharger", + "@download": {}, + "sync": "Synchroniser avec le serveur", + "@sync": {}, + "about": "À propos de Finamp", + "@about": {} +} diff --git a/lib/l10n/app_hr.arb b/lib/l10n/app_hr.arb new file mode 100644 index 0000000..142f706 --- /dev/null +++ b/lib/l10n/app_hr.arb @@ -0,0 +1,590 @@ +{ + "serverUrl": "URL servera", + "@serverUrl": {}, + "startupError": "Dogodila se greška prilikom pokretanja aplikacije. Greška: {error}\n\nPrijavi problem na github.com/UnicornsOnLSD/finamp sa snimkom ekrana ove stranice. Ako ovaj problem ustraje, izbriši svoje podatke aplikacije i resetiraj aplikaciju.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "sortOrder": "Redoslijed razvrstavanja", + "@sortOrder": {}, + "selectMusicLibraries": "Odaberi fonoteke", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "username": "Korisničko ime", + "@username": {}, + "password": "Lozinka", + "@password": {}, + "logs": "Zapisi", + "@logs": {}, + "next": "Dalje", + "@next": {}, + "genres": "Žanrovi", + "@genres": {}, + "music": "Glazba", + "@music": {}, + "name": "Ime", + "@name": {}, + "random": "Slučajno", + "@random": {}, + "revenue": "Prihod", + "@revenue": {}, + "runtime": "Vrijeme trajanja", + "@runtime": {}, + "downloadMissingImages": "Preuzmi nedostajuće slike", + "@downloadMissingImages": {}, + "noErrors": "Nema grešaka!", + "@noErrors": {}, + "failedToGetSongFromDownloadId": "Neuspjelo dohvaćanje pjesme s ID-a preuzimanja", + "@failedToGetSongFromDownloadId": {}, + "error": "Greška", + "@error": {}, + "playButtonLabel": "POKRENI", + "@playButtonLabel": {}, + "favourite": "Omiljeno", + "@favourite": {}, + "location": "Lokacija", + "@location": {}, + "applicationLegalese": "Licenca: Mozilla Public License 2.0. Izvorni kod je dostupan na:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "transcoding": "Transkodiranje", + "@transcoding": {}, + "logOut": "Odjavi se", + "@logOut": {}, + "unknownError": "Nepoznata greška", + "@unknownError": {}, + "pathReturnSlashErrorMessage": "Staze sa znakom „/” se ne mogu koristiti", + "@pathReturnSlashErrorMessage": {}, + "shuffleAllSongCount": "Broj pjesama za miješanje", + "@shuffleAllSongCount": {}, + "list": "Popis", + "@list": {}, + "portrait": "Uspravno", + "@portrait": {}, + "landscape": "Polegnuto", + "@landscape": {}, + "gridCrossAxisCount": "{value} broj rešetkastih linija", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "showTextOnGridView": "Prikaži tekst u rešetkastom prikazu", + "@showTextOnGridView": {}, + "showTextOnGridViewSubtitle": "Da li prikazati tekst (naslov, izvođač itd.) u rešetkastom ekranu glazbe.", + "@showTextOnGridViewSubtitle": {}, + "showCoverAsPlayerBackground": "Prikaži mutnu sliku omota kao pozadinu playera", + "@showCoverAsPlayerBackground": {}, + "tabs": "Kartice", + "@tabs": {}, + "streaming": "PRIJENOS", + "@streaming": {}, + "statusError": "GREŠKA STANJA", + "@statusError": {}, + "removedFromPlaylist": "Uklonjeno iz playliste.", + "@removedFromPlaylist": {}, + "startingInstantMix": "Pokretanje instant miksa.", + "@startingInstantMix": {}, + "anErrorHasOccured": "Dogodila se greška.", + "@anErrorHasOccured": {}, + "responseError401": "{error} Kȏd stanja {statusCode}. Ovo vjerojatno znači da si koristio/la pokrešno korisničko ime/lozinku ili da tvoj klijent više nije prijavljen.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "removeFromMix": "Ukloni iz miksa", + "@removeFromMix": {}, + "bufferDuration": "Trajanje međuspremnika", + "@bufferDuration": {}, + "bufferDurationSubtitle": "Količina koju player treba spremiti u međuspremnik, u sekundama. Zahtijeva ponovno pokretanje.", + "@bufferDurationSubtitle": {}, + "language": "Jezik", + "@language": {}, + "unknownName": "Nepoznato ime", + "@unknownName": {}, + "songs": "Pjesme", + "@songs": {}, + "startMixNoSongsArtist": "Pritisni dugo na izvođača za dodavanje ili uklanjanje izvođača iz miksera prije pokretanja miksa", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "urlStartWithHttps": "URL mora početi s http:// ili https://", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "artists": "Izvođači", + "@artists": {}, + "internalExternalIpExplanation": "Ako želiš pristupiti Jellyfin serveru na daljinski način moraš korisititi tvoju externu IP adresu.\n\nAko je tvoj server na HTTP priključku (80/443), ne moraš navesti priključak. To će vjerojatno slučaj ako se tvoj server nalazi iza obrnutog proxija.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "emptyServerUrl": "URL servera ne smije biti prazan", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "urlTrailingSlash": "URL ne smije sadržavati kosu crtu na kraju", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "albums": "Albumi", + "@albums": {}, + "couldNotFindLibraries": "Nije bilo moguće pronaći niti jednu fonoteku.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "playlists": "Playliste", + "@playlists": {}, + "startMix": "Pokreni miks", + "@startMix": {}, + "playCount": "Broj reprodukcija", + "@playCount": {}, + "datePlayed": "Datum reprodukcije", + "@datePlayed": {}, + "startMixNoSongsAlbum": "Pritisni dugo na album za dodavanje ili uklanjanje albuma iz miksera prije pokretanja miksa", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "shuffleAll": "Izmiješaj sve", + "@shuffleAll": {}, + "album": "Album", + "@album": {}, + "communityRating": "Ocjena zajednice", + "@communityRating": {}, + "clear": "Izbriši", + "@clear": {}, + "finamp": "Finamp", + "@finamp": {}, + "favourites": "Omiljeno", + "@favourites": {}, + "downloads": "Preuzimanja", + "@downloads": {}, + "settings": "Postavke", + "@settings": {}, + "offlineMode": "Neumrežen modus", + "@offlineMode": {}, + "sortBy": "Razvrstaj po", + "@sortBy": {}, + "productionYear": "Godina produkcije", + "@productionYear": {}, + "albumArtist": "Izvođač albuma", + "@albumArtist": {}, + "dateAdded": "Datum dodavanja", + "@dateAdded": {}, + "artist": "Izvođač", + "@artist": {}, + "budget": "Budžet", + "@budget": {}, + "premiereDate": "Datum premijere", + "@premiereDate": {}, + "criticRating": "Ocjena kritičara", + "@criticRating": {}, + "downloadedMissingImages": "{count,plural, =0{Nema nedostajućih slika} =1{Preuzeta je {count} nedostajuća slika} few{Preuzete su {count} nedostajuće slike} other{Preuzeto je {count} nedostajućih slika}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "editPlaylistNameTitle": "Uredi ime playliste", + "@editPlaylistNameTitle": {}, + "customLocation": "Prilagođena lokacija", + "@customLocation": {}, + "viewTypeSubtitle": "Vrsta prikaza za ekran glazbe", + "@viewTypeSubtitle": {}, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "discNumber": "Disk {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "downloadCount": "{count,plural, =1{{count} preuzimanje} few{{count} preuzimanja} other{{count} preuzimanja}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrors": "Greške preuzimanja", + "@downloadErrors": {}, + "downloadedItemsCount": "{count,plural,=1{{count} stavka} few{{count} stavke} other{{count} stavki}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedImagesCount": "{count,plural,=1{{count} slika} few{{count} slike} other{{count} slika}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlFailed": "{count} neuspjelo", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlComplete": "{count} dovršeno", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "Greške preuzimanja", + "@downloadErrorsTitle": {}, + "editPlaylistNameTooltip": "Uredi ime playliste", + "@editPlaylistNameTooltip": {}, + "playlistNameUpdated": "Ime playliste je ažurirano.", + "@playlistNameUpdated": {}, + "dlEnqueued": "{count} dodano u red", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "{count} u tijeku", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "errorScreenError": "Dogodila se greška prilikom dohvaćanja popisa grešaka! Prijavi problem na GitHubu i izbriši podatke aplikacije", + "@errorScreenError": {}, + "shuffleButtonLabel": "IZMIJEŠAJ", + "@shuffleButtonLabel": {}, + "addDownloads": "Dodaj preuzimanja", + "@addDownloads": {}, + "songCount": "{count,plural,=1{{count} pjesma} few{{count} pjesme} other{{count} pjesama}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "required": "Obavezno", + "@required": {}, + "downloadsAdded": "Preuzimanja dodana.", + "@downloadsAdded": {}, + "updateButtonLabel": "AŽURIRAJ", + "@updateButtonLabel": {}, + "downloadsDeleted": "Preuzimanja izbrisana.", + "@downloadsDeleted": {}, + "addButtonLabel": "DODAJ", + "@addButtonLabel": {}, + "shareLogs": "Dijeli zapise", + "@shareLogs": {}, + "logsCopied": "Zapisi su kopirani.", + "@logsCopied": {}, + "message": "Poruka", + "@message": {}, + "stackTrace": "Trag Stacka", + "@stackTrace": {}, + "layoutAndTheme": "Izgled i tema", + "@layoutAndTheme": {}, + "customLocationsBuggy": "Prilagođene lokacije su izrazito pune grešaka zbog problema oko dozvola. Razmišljam o načinima da ovo ispravim, ali za sada ne bih preporučio korištenje.", + "@customLocationsBuggy": {}, + "shuffleAllSongCountSubtitle": "Broj pjesama koje se učitavaju kada se koristi gumb „Izmiješaj sve pjesme”.", + "@shuffleAllSongCountSubtitle": {}, + "noArtist": "Nema izvođača", + "@noArtist": {}, + "downloadLocations": "Lokacija preuzimanja", + "@downloadLocations": {}, + "audioService": "Usluga audioreprodukcije", + "@audioService": {}, + "areYouSure": "Jeste li sigurni?", + "@areYouSure": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin koristi AAC za transkodiranje", + "@jellyfinUsesAACForTranscoding": {}, + "notAvailableInOfflineMode": "Nije dostupno u neumreženom modusu", + "@notAvailableInOfflineMode": {}, + "addDownloadLocation": "Dodaj lokaciju preuzimanja", + "@addDownloadLocation": {}, + "directoryMustBeEmpty": "Direktorij mora biti prazan", + "@directoryMustBeEmpty": {}, + "enterLowPriorityStateOnPauseSubtitle": "Omogućuje brisanje obavijesti kada je pauzirano. Također omogućuje Androidu da prekine uslugu kada je pauzirana.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "grid": "Rešetka", + "@grid": {}, + "showCoverAsPlayerBackgroundSubtitle": "Da li koristiti mutnu sliku omota kao pozadinu na ekranu playera.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "disableGesture": "Deaktiviraj geste", + "@disableGesture": {}, + "theme": "Tema", + "@theme": {}, + "dark": "Tamna", + "@dark": {}, + "downloadedSongsWillNotBeDeleted": "Preuzete pjesme neće biti izbrisane", + "@downloadedSongsWillNotBeDeleted": {}, + "bitrate": "Brzina prijenosa", + "@bitrate": {}, + "bitrateSubtitle": "Veća brzina prijenosa daje veću kvalitetu zvuka, ali troši veću količinu prometa.", + "@bitrateSubtitle": {}, + "setSleepTimer": "Postavi odbrojavanje", + "@setSleepTimer": {}, + "downloaded": "PREUZETO", + "@downloaded": {}, + "selectDirectory": "Odaberi direktorij", + "@selectDirectory": {}, + "enableTranscoding": "Omogući transkodiranje", + "@enableTranscoding": {}, + "enableTranscodingSubtitle": "Transkodira stream glazbe na server strani.", + "@enableTranscodingSubtitle": {}, + "addToPlaylistTooltip": "Dodaj u playlistu", + "@addToPlaylistTooltip": {}, + "goToAlbum": "Idi na album", + "@goToAlbum": {}, + "appDirectory": "Direktorij aplikacije", + "@appDirectory": {}, + "enterLowPriorityStateOnPause": "Unesite stanje niskog prioriteta za vrijeme pauze", + "@enterLowPriorityStateOnPause": {}, + "gridCrossAxisCountSubtitle": "Količina rešetkastih polja po redu kada {value}.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "hideSongArtistsIfSameAsAlbumArtists": "Sakrij izvođače pjesama ako su isti kao izvođači albuma", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "minutes": "Minute", + "@minutes": {}, + "viewType": "Vrsta prikaza", + "@viewType": {}, + "createButtonLabel": "STVORI", + "@createButtonLabel": {}, + "sleepTimerTooltip": "Odbrojavanje", + "@sleepTimerTooltip": {}, + "addToPlaylistTitle": "Dodaj u playlistu", + "@addToPlaylistTitle": {}, + "removeFromPlaylistTitle": "Ukloni iz playliste", + "@removeFromPlaylistTitle": {}, + "newPlaylist": "Nova playlista", + "@newPlaylist": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Da li prikazati izvođače pjesama na ekranu albuma ako se ne razlikuju od izvođača albuma.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "disableGestureSubtitle": "Da li deaktivirati geste.", + "@disableGestureSubtitle": {}, + "system": "Sustav", + "@system": {}, + "light": "Svijetla", + "@light": {}, + "cancelSleepTimer": "Prekinuti odbrojavanje?", + "@cancelSleepTimer": {}, + "yesButtonLabel": "DA", + "@yesButtonLabel": {}, + "noButtonLabel": "NE", + "@noButtonLabel": {}, + "invalidNumber": "Neispravan broj", + "@invalidNumber": {}, + "removeFromPlaylistTooltip": "Ukloni iz playliste", + "@removeFromPlaylistTooltip": {}, + "unknownArtist": "Nepoznat izvođač", + "@unknownArtist": {}, + "transcode": "TRANSKODIRAJ", + "@transcode": {}, + "playlistCreated": "Playlista je stvorena.", + "@playlistCreated": {}, + "noAlbum": "Nema albuma", + "@noAlbum": {}, + "noItem": "Nema stavki", + "@noItem": {}, + "queue": "Red čekanja", + "@queue": {}, + "direct": "DIREKTNO", + "@direct": {}, + "addToQueue": "Dodaj u red čekanja", + "@addToQueue": {}, + "instantMix": "Instant miks", + "@instantMix": {}, + "responseError": "{error} Kȏd stanja {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "replaceQueue": "Zamijeni red čekanja", + "@replaceQueue": {}, + "addedToQueue": "Dodano u red čekanja.", + "@addedToQueue": {}, + "removeFavourite": "Izbriši omiljene", + "@removeFavourite": {}, + "addFavourite": "Dodaj omiljene", + "@addFavourite": {}, + "queueReplaced": "Red čekanja je zamijenjen.", + "@queueReplaced": {}, + "addToMix": "Dodaj u miks", + "@addToMix": {}, + "redownloadedItems": "{count,plural, =0{Ponovna preuzimanja nisu potrebna.} =1{Ponovo je preuzeta {count} stavka} few{Ponovo su preuzete {count} stavke} other{Ponovo je preuzeto {count} stavki}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "confirm": "Potvrdi", + "@confirm": {}, + "showUncensoredLogMessage": "Ovaj zapis sadrži tvoje podatke za prijavu. Prikazati?", + "@showUncensoredLogMessage": {}, + "resetTabs": "Resetiraj kartice", + "@resetTabs": {}, + "insertedIntoQueue": "Umetnuto u red čekanja.", + "@insertedIntoQueue": { + "description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end." + }, + "playNext": "Reproduciraj kao sljedeću", + "@playNext": { + "description": "Popup menu item title for inserting an item into the play queue after the currently-playing item." + }, + "refresh": "AKTUALIZIRAJ", + "@refresh": {}, + "noMusicLibrariesBody": "Finamp nije mogao pronaći nijednu fonoteku. Provjeri sadrži li tvoj Jellyfin poslužitelj barem jednu biblioteku s vrstom sadržaja postavljenom na „Glazba”.", + "@noMusicLibrariesBody": {}, + "noMusicLibrariesTitle": "Nema fonoteka", + "@noMusicLibrariesTitle": { + "description": "Title for message that shows on the views screen when no music libraries could be found." + }, + "deleteDownloadsConfirmButtonText": "Izbriši", + "@deleteDownloadsConfirmButtonText": { + "description": "Shown in the confirmation dialog for deleting downloaded media from the local device." + }, + "syncDownloadedPlaylists": "Sinkroniziraj preuzete playliste", + "@syncDownloadedPlaylists": {}, + "deleteDownloadsAbortButtonText": "Odustani", + "@deleteDownloadsAbortButtonText": {}, + "showFastScroller": "Prikaži traku za brzo listanje", + "@showFastScroller": {}, + "deleteDownloadsPrompt": "Stvarno želiš izbrisati {itemType, select, album{album} playlist{playlistu} artist{izvođača} genre{žanr} track{pjesmu} other{}} '{itemName}' s ovog uređaja?", + "@deleteDownloadsPrompt": { + "placeholders": { + "itemName": { + "type": "String", + "example": "Abandon Ship" + }, + "itemType": { + "type": "String", + "example": "album" + } + }, + "description": "Confirmation prompt shown before deleting downloaded media from the local device, destructive action, doesn't affect the media on the server." + }, + "swipeInsertQueueNextSubtitle": "Omogući umetanje pjesme kao sljedeću pjesmu u redu reprodukcije povlačenjem pjesme iz popisa pjesama umjesto dodavanja pjesme na kraj popisa.", + "@swipeInsertQueueNextSubtitle": {}, + "interactions": "Interakcije", + "@interactions": {}, + "swipeInsertQueueNext": "Reproduciraj pjesmu kao sljedeću povlačenjem", + "@swipeInsertQueueNext": {}, + "redesignBeta": "Probaj beta verziju", + "@redesignBeta": {}, + "skipToPrevious": "Prijeđi na prethodnu pjesmu", + "@skipToPrevious": {}, + "skipToNext": "Prijeđi na sljedeću pjesmu", + "@skipToNext": {}, + "deleteFromDevice": "Izbriši s uređaja", + "@deleteFromDevice": {}, + "download": "Preuzmi", + "@download": {}, + "sync": "Sinkroniziraj sa serverom", + "@sync": {}, + "playbackOrderShuffledTooltip": "Miješanje. Uključi/isključi dodirom.", + "@playbackOrderShuffledTooltip": {}, + "playbackOrderLinearTooltip": "Reprodukcija redom. Uključi/isključi dodirom.", + "@playbackOrderLinearTooltip": {}, + "togglePlayback": "Uključi/isključi reprodukciju", + "@togglePlayback": {}, + "loopModeAllTooltip": "Ponavljanje svih. Uključi/isključi dodirom.", + "@loopModeAllTooltip": {}, + "loopModeOneTooltip": "Ponavljanje jedne. Uključi/isključi dodirom.", + "@loopModeOneTooltip": {}, + "about": "O aplikaciji Finamp", + "@about": {}, + "shuffleArtist": "Izmiješaj sve albume od {artist}", + "@shuffleArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "playArtist": "Reproduciraj sve albume od {artist}", + "@playArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "downloadArtist": "Preuzmi sve albume od {artist}", + "@downloadArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "loopModeNoneTooltip": "Bez ponavljanja. Uključi/isključi dodirom.", + "@loopModeNoneTooltip": {} +} diff --git a/lib/l10n/app_hu.arb b/lib/l10n/app_hu.arb new file mode 100644 index 0000000..c173583 --- /dev/null +++ b/lib/l10n/app_hu.arb @@ -0,0 +1,477 @@ +{ + "applicationLegalese": "A Mozilla Public License 2.0 licenccel. A forráskód elérhető:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "sleepTimerTooltip": "Elalvás időzítő", + "@sleepTimerTooltip": {}, + "shuffleAllSongCount": "Az összes szám véletlenszerű lejátszása", + "@shuffleAllSongCount": {}, + "startupError": "Hiba történt az alkalmazás indításakor! A hiba a következő volt: {error}\n\nKérjük, hozzon létre egyGithub-problémát a github.com/UnicornsOnLSD/finamp oldalon az oldal képernyőképével. Ha a probléma továbbra is fennáll, törölje az alkalmazás adatait az alkalmazás visszaállításához.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "updateButtonLabel": "FRISSÍTÉS", + "@updateButtonLabel": {}, + "urlStartWithHttps": "Az URL-nek \"http://\" vagy \"https://\" előtaggal kell kezdődnie", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "username": "Felhasználónév", + "@username": {}, + "gridCrossAxisCount": "{value} rács kereszttengelyek száma", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "viewTypeSubtitle": "Nézettípus a zenei képernyőhöz", + "@viewTypeSubtitle": {}, + "viewType": "Nézet típusa", + "@viewType": {}, + "serverUrl": "Szerver webcíme", + "@serverUrl": {}, + "album": "Album", + "@album": {}, + "albumArtist": "Album előadó", + "@albumArtist": {}, + "albums": "Albumok", + "@albums": {}, + "artist": "Előadó", + "@artist": {}, + "artists": "Előadók", + "@artists": {}, + "budget": "Költségvetés", + "@budget": {}, + "communityRating": "Közösségi értékelés", + "@communityRating": {}, + "criticRating": "Kritikusok értékelése", + "@criticRating": {}, + "dateAdded": "Hozzáadás dátuma", + "@dateAdded": {}, + "datePlayed": "Lejátszva ekkor", + "@datePlayed": {}, + "downloadErrors": "Letöltési hibák", + "@downloadErrors": {}, + "downloadMissingImages": "Töltse le a hiányzó képeket", + "@downloadMissingImages": {}, + "favourites": "Kedvencek", + "@favourites": {}, + "finamp": "Finamp", + "@finamp": {}, + "genres": "Műfajok", + "@genres": {}, + "logs": "Naplók", + "@logs": {}, + "startMixNoSongsAlbum": "Nyomd meg hosszan az albumot, hogy hozzáadja vagy eltávolítsa őket a mixkészítőből, mielőtt elkezdené a keverést", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "startMixNoSongsArtist": "Nyomd meg hosszan az előadót, hogy hozzáadja vagy eltávolítsa őket a mixkészítőből, mielőtt elkezdené a keverést", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "music": "Zene", + "@music": {}, + "name": "Név", + "@name": {}, + "next": "Következő", + "@next": {}, + "offlineMode": "Offline mód", + "@offlineMode": {}, + "password": "Jelszó", + "@password": {}, + "playCount": "Lejátszások száma", + "@playCount": {}, + "playlists": "Lejátszási listák", + "@playlists": {}, + "premiereDate": "Premier dátuma", + "@premiereDate": {}, + "productionYear": "Gyártási év", + "@productionYear": {}, + "random": "Véletlen", + "@random": {}, + "revenue": "Bevétel", + "@revenue": {}, + "runtime": "Futásidő", + "@runtime": {}, + "selectMusicLibraries": "Zenei könyvtárak kiválasztása", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "emptyServerUrl": "A szerver URL-címe nem lehet üres", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "settings": "Beállítások", + "@settings": {}, + "shuffleAll": "Összes keverése", + "@shuffleAll": {}, + "songs": "Dalok", + "@songs": {}, + "sortBy": "Sorrend eszerint", + "@sortBy": {}, + "sortOrder": "Rendezési sorrend", + "@sortOrder": {}, + "startMix": "Mix elindítása", + "@startMix": {}, + "unknownName": "Ismeretlen név", + "@unknownName": {}, + "addDownloads": "Letöltések hozzáadása", + "@addDownloads": {}, + "areYouSure": "Biztos vagy ebben?", + "@areYouSure": {}, + "audioService": "Hangszolgáltatás", + "@audioService": {}, + "bitrate": "Bitráta", + "@bitrate": {}, + "dlComplete": "{count} kész", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlEnqueued": "{count} a sorban", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlFailed": "{count} nem sikerült", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "discNumber": "CD {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "downloadedSongsWillNotBeDeleted": "A letöltött dalok nem törlődnek", + "@downloadedSongsWillNotBeDeleted": {}, + "downloadErrorsTitle": "Letöltési Hibák", + "@downloadErrorsTitle": {}, + "downloadLocations": "Letöltési hely", + "@downloadLocations": {}, + "downloadsAdded": "Letöltések hozzáadva.", + "@downloadsAdded": {}, + "downloadsDeleted": "Letöltések törölve.", + "@downloadsDeleted": {}, + "editPlaylistNameTooltip": "Lejátszási lista címének szerkesztése", + "@editPlaylistNameTooltip": {}, + "editPlaylistNameTitle": "Lejátszási lista címének szerkesztése", + "@editPlaylistNameTitle": {}, + "enableTranscoding": "Transzkódolás engedélyezése", + "@enableTranscoding": {}, + "error": "Hiba", + "@error": {}, + "failedToGetSongFromDownloadId": "Nem sikerült letölteni a dalt, a letöltési azonosítóból", + "@failedToGetSongFromDownloadId": {}, + "favourite": "Kedvenc", + "@favourite": {}, + "enableTranscodingSubtitle": "Transzkódolja a zenestreameket a szerver oldalon.", + "@enableTranscodingSubtitle": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin AAC-t használ az transzkódoláshoz", + "@jellyfinUsesAACForTranscoding": {}, + "layoutAndTheme": "Elrendezés & Téma", + "@layoutAndTheme": {}, + "location": "Elhelyezkedés", + "@location": {}, + "logOut": "Kijelentkezés", + "@logOut": {}, + "logsCopied": "A naplók másolva.", + "@logsCopied": {}, + "message": "Üzenet", + "@message": {}, + "noErrors": "Nincsenek hibák!", + "@noErrors": {}, + "notAvailableInOfflineMode": "Offline módban nem elérhető", + "@notAvailableInOfflineMode": {}, + "playButtonLabel": "LEJÁTSZÁS", + "@playButtonLabel": {}, + "playlistNameUpdated": "A lejátszási lista neve frissítve.", + "@playlistNameUpdated": {}, + "required": "Szükséges", + "@required": {}, + "shareLogs": "Naplók megosztása", + "@shareLogs": {}, + "shuffleButtonLabel": "KEVERÉS", + "@shuffleButtonLabel": {}, + "stackTrace": "Kötegelt visszalépés", + "@stackTrace": {}, + "transcoding": "Transzkódolás", + "@transcoding": {}, + "dlRunning": "{count} Fut", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "addDownloadLocation": "Letöltési hely hozzáadása", + "@addDownloadLocation": {}, + "bitrateSubtitle": "A nagyobb bitráta jobb hangminőséget biztosít nagyobb sávszélesség mellett.", + "@bitrateSubtitle": {}, + "gridCrossAxisCountSubtitle": "A soronként használandó rácscsempék száma {value} esetén.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "shuffleAllSongCountSubtitle": "A betöltendő zeneszámok száma az \"Összes dal keverése\" gombbal.", + "@shuffleAllSongCountSubtitle": {}, + "appDirectory": "Alkalmazás Könyvtára", + "@appDirectory": {}, + "customLocation": "Egyéni hely", + "@customLocation": {}, + "customLocationsBuggy": "Az egyéni helyek rendkívül bugosak az engedélyekkel kapcsolatos problémák miatt. Gondolkozom a megoldáson, de egyelőre nem javaslom ezek használatát.", + "@customLocationsBuggy": {}, + "directoryMustBeEmpty": "A könyvtárnak üresnek kell lennie", + "@directoryMustBeEmpty": {}, + "enterLowPriorityStateOnPause": "Lépjen az Alacsony-prioritású állapotba a Szünet módban", + "@enterLowPriorityStateOnPause": {}, + "grid": "Rács", + "@grid": {}, + "landscape": "Fekvő", + "@landscape": {}, + "list": "Lista", + "@list": {}, + "pathReturnSlashErrorMessage": "A „/” karaktert használó elérési utak nem értelmezhetőek", + "@pathReturnSlashErrorMessage": {}, + "portrait": "Álló", + "@portrait": {}, + "selectDirectory": "Könyvtár kiválasztása", + "@selectDirectory": {}, + "showCoverAsPlayerBackground": "Elmosódott borító megjelenítése lejátszási háttérként", + "@showCoverAsPlayerBackground": {}, + "showTextOnGridView": "Szöveg megjelenítése rácsnézetben", + "@showTextOnGridView": {}, + "system": "Rendszer", + "@system": {}, + "theme": "Téma", + "@theme": {}, + "unknownError": "Ismeretlen hiba", + "@unknownError": {}, + "addedToQueue": "Hozzáadva a sorhoz.", + "@addedToQueue": {}, + "addFavourite": "Hozzáadás a kedvencekhez", + "@addFavourite": {}, + "responseError": "{error} Állapotkód {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "responseError401": "{error} Állapotkód: {statusCode}. Ez valószínűleg azt jelenti, hogy rossz felhasználónevet/jelszót használt, vagy a kliens már nincs hitelesítve.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "addToPlaylistTooltip": "Hozzáadás a lejátszási listához", + "@addToPlaylistTooltip": {}, + "addToPlaylistTitle": "Hozzáadás a Lejátszási Listához", + "@addToPlaylistTitle": {}, + "addToQueue": "Hozzáadás a sorhoz", + "@addToQueue": {}, + "anErrorHasOccured": "Hiba történt.", + "@anErrorHasOccured": {}, + "errorScreenError": "Hiba történt a hibalista lekérésekor! Ezen a ponton valószínűleg csak problémát kell létrehoznia a GitHubon, és törölnie kell az alkalmazásadatokat", + "@errorScreenError": {}, + "cancelSleepTimer": "Törlöd az elalvásidőzítőt?", + "@cancelSleepTimer": {}, + "clear": "Törlés", + "@clear": {}, + "couldNotFindLibraries": "Nem található könyvtár.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "dark": "Sötét", + "@dark": {}, + "direct": "DIREKT", + "@direct": {}, + "downloaded": "LETÖLTVE", + "@downloaded": {}, + "goToAlbum": "Ugrás az albumra", + "@goToAlbum": {}, + "instantMix": "Instant Keverés", + "@instantMix": {}, + "light": "Világos", + "@light": {}, + "newPlaylist": "Új lejátszási lista", + "@newPlaylist": {}, + "noButtonLabel": "NEM", + "@noButtonLabel": {}, + "noAlbum": "Album nélkül", + "@noAlbum": {}, + "noArtist": "Nincs előadó", + "@noArtist": {}, + "noItem": "Nincs tárgy", + "@noItem": {}, + "playlistCreated": "Lejátszási lista létrehozva.", + "@playlistCreated": {}, + "queue": "Sor", + "@queue": {}, + "queueReplaced": "Sor cserélve.", + "@queueReplaced": {}, + "removeFavourite": "Kedvenc eltávolítása", + "@removeFavourite": {}, + "removeFromMix": "Kivétel a Mixből", + "@removeFromMix": {}, + "replaceQueue": "Sor kicserélése", + "@replaceQueue": {}, + "setSleepTimer": "Elalvásidőzítő beállítása", + "@setSleepTimer": {}, + "startingInstantMix": "Instant mix indítása.", + "@startingInstantMix": {}, + "statusError": "ÁLLAPOT HIBA", + "@statusError": {}, + "streaming": "STREAMELÉS", + "@streaming": {}, + "tabs": "Tabok", + "@tabs": {}, + "transcode": "TRANSZKÓD", + "@transcode": {}, + "unknownArtist": "Ismeretlen Előadó", + "@unknownArtist": {}, + "addButtonLabel": "HOZZÁADÁS", + "@addButtonLabel": {}, + "createButtonLabel": "LÉTREHOZÁS", + "@createButtonLabel": {}, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "invalidNumber": "Érvénytelen szám", + "@invalidNumber": {}, + "downloads": "Letöltések", + "@downloads": {}, + "hideSongArtistsIfSameAsAlbumArtists": "A dal előadóinak elrejtése, ha megegyezik az album előadóival", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "internalExternalIpExplanation": "Ha távolról szeretné elérni Jellyfin szerverét, akkor külső IP-címét kell használnia.\n\nHa a szerver HTTP porton (80/443) van, akkor nem kell portot megadnia. Valószínűleg ez a helyzet akkor, ha a szervere fordított proxy mögött van.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "urlTrailingSlash": "Az URL végén nem lehet \"/\"-jel", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "downloadedMissingImages": "{count,plural, =0{Nem található hiányzó kép} =1{Letöltődött {count} hiányzó kép} other{Letöltött {count} hiányzó képet}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsCount": "{count,plural,=1{{count} item} other{{count} items}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "enterLowPriorityStateOnPauseSubtitle": "Lehetővé teszi az értesítés elcsúsztatását, amikor szünetel. Azt is lehetővé teszi, hogy az Android leállítsa a szolgáltatást, amikor szünetel.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Megjeleníti-e a dal előadóit az album képernyőjén, ha nem különbözik az album előadóitól.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "yesButtonLabel": "IGEN", + "@yesButtonLabel": {}, + "addToMix": "Hozzáadás a Mix-hez", + "@addToMix": {}, + "showTextOnGridViewSubtitle": "Megjelenik-e a szöveg (cím, előadó stb.) a rácszene képernyőn.", + "@showTextOnGridViewSubtitle": {}, + "showCoverAsPlayerBackgroundSubtitle": "Használja-e az elmosódott borítót háttérként a lejátszó képernyőjén.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "downloadCount": "{count,plural, =1{{count} download} other{{count} downloads}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedImagesCount": "{count,plural,=1{{count} image} other{{count} images}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "songCount": "{count,plural,=1{{count} Song} other{{count} Songs}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "minutes": "Percek", + "@minutes": {}, + "redownloadedItems": "{count,plural, =0{Nincs szükség újratöltésre..} =1{ {count} elem újra letöltve} other{{count} elemek újra letöltve}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "bufferDuration": "Puffer időtartama", + "@bufferDuration": {}, + "bufferDurationSubtitle": "Mennyit kell pufferelnie a lejátszónak másodpercben. Újraindítást igényel.", + "@bufferDurationSubtitle": {}, + "disableGesture": "Gesztusok letiltása", + "@disableGesture": {}, + "disableGestureSubtitle": "A gesztusok letiltása.", + "@disableGestureSubtitle": {} +} diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb new file mode 100644 index 0000000..2d044a8 --- /dev/null +++ b/lib/l10n/app_it.arb @@ -0,0 +1,515 @@ +{ + "serverUrl": "URL del Server", + "@serverUrl": {}, + "startupError": "Qualcosa è andato storto durante l'avvio dell'app. L'errore è stato: {error}\n\nPerfavore apri una segnalazione su github.com/UnicornsOnLSD/finamp con uno screenshot di questa pagina. Se il problema persiste puoi cancellare i dati dell'app per riportarla alle impostazioni iniziali.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "emptyServerUrl": "l'URL del Server non può essere vuoto", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "urlStartWithHttps": "l'URL deve iniziare con http:// o https://", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "logs": "Log", + "@logs": {}, + "next": "Avanti", + "@next": {}, + "password": "Password", + "@password": {}, + "urlTrailingSlash": "l'URL non deve contenere il carattere (/) alla fine", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "username": "Nome utente", + "@username": {}, + "artists": "Artisti", + "@artists": {}, + "genres": "Generi", + "@genres": {}, + "songs": "Brani", + "@songs": {}, + "startMix": "Riproduco Mix", + "@startMix": {}, + "unknownName": "Nome Sconosciuto", + "@unknownName": {}, + "clear": "Cancella", + "@clear": {}, + "music": "Musica", + "@music": {}, + "favourites": "Preferiti", + "@favourites": {}, + "finamp": "Finamp", + "@finamp": {}, + "offlineMode": "Modailtà Offline", + "@offlineMode": {}, + "settings": "Impostazioni", + "@settings": {}, + "sortOrder": "Ordinamento", + "@sortOrder": {}, + "album": "Album", + "@album": {}, + "artist": "Artista", + "@artist": {}, + "communityRating": "Valutazione della comunità", + "@communityRating": {}, + "dateAdded": "Data di Aggiunta", + "@dateAdded": {}, + "datePlayed": "Data di Riproduzione", + "@datePlayed": {}, + "playCount": "Conteggio Riproduzioni", + "@playCount": {}, + "premiereDate": "Data di Rilascio", + "@premiereDate": {}, + "budget": "Budget", + "@budget": {}, + "downloadMissingImages": "Scarica immagini mancanti", + "@downloadMissingImages": {}, + "name": "Nome", + "@name": {}, + "productionYear": "Anno di Produzione", + "@productionYear": {}, + "random": "Casuale", + "@random": {}, + "runtime": "Tempo di esecuzione", + "@runtime": {}, + "downloadErrors": "Errori durante il download", + "@downloadErrors": {}, + "dlComplete": "{count} finito", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "discNumber": "Disco {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "Errori durante il Download", + "@downloadErrorsTitle": {}, + "error": "Errore", + "@error": {}, + "failedToGetSongFromDownloadId": "Impossibile ottenere il brano dall'ID di download", + "@failedToGetSongFromDownloadId": {}, + "noErrors": "Nessun errore!", + "@noErrors": {}, + "playButtonLabel": "RIPRODUCI", + "@playButtonLabel": {}, + "shuffleButtonLabel": "RIPRODUCI CASUALMENTE", + "@shuffleButtonLabel": {}, + "downloadsDeleted": "Download cancellati.", + "@downloadsDeleted": {}, + "editPlaylistNameTitle": "Modifica il Nome della Playlist", + "@editPlaylistNameTitle": {}, + "favourite": "Preferiti", + "@favourite": {}, + "location": "Posizione", + "@location": {}, + "playlistNameUpdated": "Playlist nome aggiornato.", + "@playlistNameUpdated": {}, + "required": "Richiesto", + "@required": {}, + "updateButtonLabel": "AGGIORNAMENTO", + "@updateButtonLabel": {}, + "downloadsAdded": "Download aggiunti.", + "@downloadsAdded": {}, + "logsCopied": "Log copiati.", + "@logsCopied": {}, + "message": "Messaggio", + "@message": {}, + "shareLogs": "Condividi i log", + "@shareLogs": {}, + "transcoding": "Transcodifica", + "@transcoding": {}, + "audioService": "Servizio Audio", + "@audioService": {}, + "downloadLocations": "Cartella di Download", + "@downloadLocations": {}, + "layoutAndTheme": "Aspetto & Temi", + "@layoutAndTheme": {}, + "logOut": "Disconnetti", + "@logOut": {}, + "notAvailableInOfflineMode": "Non disponibile in modalità offline", + "@notAvailableInOfflineMode": {}, + "bitrate": "Bitrate", + "@bitrate": {}, + "enableTranscoding": "Attiva Transcodifica", + "@enableTranscoding": {}, + "enableTranscodingSubtitle": "Transcodifica I flussi di musica dal lato server.", + "@enableTranscodingSubtitle": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin usa AAC per la transcodifica", + "@jellyfinUsesAACForTranscoding": {}, + "appDirectory": "Cartella dell'App", + "@appDirectory": {}, + "directoryMustBeEmpty": "La cartella deve essere vuota", + "@directoryMustBeEmpty": {}, + "pathReturnSlashErrorMessage": "Non possono essere usati percorsi che terminano con \"/\"", + "@pathReturnSlashErrorMessage": {}, + "selectDirectory": "Seleziona Cartella", + "@selectDirectory": {}, + "unknownError": "Errore Sconosciuto", + "@unknownError": {}, + "enterLowPriorityStateOnPause": "Entra nello Stato a Bassa-Priorità quando in Pausa", + "@enterLowPriorityStateOnPause": {}, + "shuffleAllSongCount": "Riproduci Casualmente Tutte Le Canzoni Conteggiate", + "@shuffleAllSongCount": {}, + "grid": "Griglia", + "@grid": {}, + "landscape": "Visualizzazione orizzontale", + "@landscape": {}, + "list": "Lista", + "@list": {}, + "portrait": "Visualizzazione verticale", + "@portrait": {}, + "viewTypeSubtitle": "Modalità di visualizzazione per la schermata musica", + "@viewTypeSubtitle": {}, + "viewType": "Modalità di Visualizzazione", + "@viewType": {}, + "gridCrossAxisCountSubtitle": "Numero di colonne da usare per ciascuna riga quando {value}.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "showTextOnGridView": "Mostra testo nella vista a griglia", + "@showTextOnGridView": {}, + "gridCrossAxisCount": "{value} Numero di Colonne nella Vista a griglia", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "hideSongArtistsIfSameAsAlbumArtists": "Nascondi l'artista del brano se uguale all'artista dell'album", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "showCoverAsPlayerBackground": "Mostra le copertine sfocate quando il player è in background", + "@showCoverAsPlayerBackground": {}, + "showTextOnGridViewSubtitle": "Mostrare il testo (titolo, artista, etc.) nella Vista a griglia della schermata della musica.", + "@showTextOnGridViewSubtitle": {}, + "showCoverAsPlayerBackgroundSubtitle": "Usare o meno cover sfocate come sfondo nella schermata di riproduzione.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "light": "Chiaro", + "@light": {}, + "noButtonLabel": "NO", + "@noButtonLabel": {}, + "setSleepTimer": "Imposta Timer di Spegnimento", + "@setSleepTimer": {}, + "system": "Sistema", + "@system": {}, + "tabs": "Schede", + "@tabs": {}, + "theme": "Tema", + "@theme": {}, + "yesButtonLabel": "SI", + "@yesButtonLabel": {}, + "invalidNumber": "Numero non valido", + "@invalidNumber": {}, + "newPlaylist": "Nuova Playlist", + "@newPlaylist": {}, + "sleepTimerTooltip": "Timer di spegnimento", + "@sleepTimerTooltip": {}, + "playlistCreated": "Playlist creata.", + "@playlistCreated": {}, + "streaming": "STREAMING", + "@streaming": {}, + "unknownArtist": "Artista Sconosciuto", + "@unknownArtist": {}, + "downloaded": "Scaricato", + "@downloaded": {}, + "instantMix": "Mix Istantaneo", + "@instantMix": {}, + "queue": "Coda", + "@queue": {}, + "replaceQueue": "Sostituisci Coda", + "@replaceQueue": {}, + "statusError": "ERRORE DI STATO", + "@statusError": {}, + "transcode": "TRANSCODIFICA", + "@transcode": {}, + "queueReplaced": "Coda sostituita.", + "@queueReplaced": {}, + "removeFavourite": "Rimuovi Preferito", + "@removeFavourite": {}, + "addDownloads": "Aggiungi Download", + "@addDownloads": {}, + "addToPlaylistTooltip": "Aggiungi alla playlist", + "@addToPlaylistTooltip": {}, + "responseError": "{error} Status code {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "addedToQueue": "Aggiunto alla coda.", + "@addedToQueue": {}, + "responseError401": "{error} Codice errore {statusCode}. Questo probabilmente significa che hai usato username/password errati, o il tuo client non è più autenticato.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "addButtonLabel": "AGGIUNGI", + "@addButtonLabel": {}, + "addDownloadLocation": "Aggiungi la posizione per i Download", + "@addDownloadLocation": {}, + "addFavourite": "Aggiungi Favorito", + "@addFavourite": {}, + "addToPlaylistTitle": "Aggiungi alla Playlist", + "@addToPlaylistTitle": {}, + "addToQueue": "Aggiungi alla Coda", + "@addToQueue": {}, + "albumArtist": "Artista dell'Album", + "@albumArtist": {}, + "albums": "Album", + "@albums": {}, + "bitrateSubtitle": "Un bitrate più alto permette di avere una qualità audio più alta al costo di un più alto consumo della connessione dati.", + "@bitrateSubtitle": {}, + "shuffleAllSongCountSubtitle": "Numero di brani da caricare quando si usa il tasto per la riproduzione casuale di tutti i brani.", + "@shuffleAllSongCountSubtitle": {}, + "errorScreenError": "Si è verificato un errore mentre tentavo di ottenere la lista degli errori! A questo punto probabilmente dovresti aprire una issue su GitHub e cancellare i dati dell'app", + "@errorScreenError": {}, + "anErrorHasOccured": "Si è verificato un errore.", + "@anErrorHasOccured": {}, + "cancelSleepTimer": "Cancellare il Timer di spegnimento?", + "@cancelSleepTimer": {}, + "areYouSure": "Sei sicuro?", + "@areYouSure": {}, + "couldNotFindLibraries": "Non è possibile trovare alcuna libreria.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "downloadedMissingImages": "{count,plural, =0{Non ho trovato alcuna immagine mancante} =1{Scaricata {count} immagine mancante} other{Scaricate {count} immagini mancanti}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedSongsWillNotBeDeleted": "I brani scaricati non saranno cancellati", + "@downloadedSongsWillNotBeDeleted": {}, + "downloads": "Download", + "@downloads": {}, + "editPlaylistNameTooltip": "Modifica il nome della playlist", + "@editPlaylistNameTooltip": {}, + "internalExternalIpExplanation": "Se vuoi accedere al tuo server di Jellyfin da remoto devi usare il tuo IP pubblico.\n\nSe il tuo server usa una porta HTTP (80/443) non è necessario specificare la porta. Può verificarsi se il tuo server ad esempio si trova dietro un reverse proxy.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "noItem": "Nessun Oggetto", + "@noItem": {}, + "goToAlbum": "Vai all'Album", + "@goToAlbum": {}, + "startMixNoSongsAlbum": "Effettua una pressione prolungata su un album per aggiungerlo o rimuoverlo dal mix builder prima di far partire un mix", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "applicationLegalese": "Concesso in licenza con \"Mozilla Public License 2.0\". Codice Sorgente disponibile su:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "startMixNoSongsArtist": "Effettua una pressione prolungata su un artista per aggiungerlo o rimuoverlo dal mix builder prima di far partire un mix", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "noAlbum": "Nessun Album", + "@noAlbum": {}, + "selectMusicLibraries": "Seleziona le Librerie Musicali", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "noArtist": "Nessun Artista", + "@noArtist": {}, + "playlists": "Playlist", + "@playlists": {}, + "shuffleAll": "Riproduci casualmente tutti", + "@shuffleAll": {}, + "sortBy": "Ordina per", + "@sortBy": {}, + "startingInstantMix": "Riproduco un mix istantaneo.", + "@startingInstantMix": {}, + "enterLowPriorityStateOnPauseSubtitle": "Permette di scartare una notifica quando la riproduzione è in pausa. Inoltre consente ad Android di terminare il servizio quando la riproduzione è in pausa.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Se mostrare gli artisti del brano nella visualizzazione album nel caso coincidano con gli artisti dell'album.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "dlFailed": "{count} falliti", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "criticRating": "Punteggio della critica", + "@criticRating": {}, + "revenue": "Incassi", + "@revenue": {}, + "downloadCount": "{count,plural, =1{{count} download} other{{count} download}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlEnqueued": "{count} in coda", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "{count} in riproduzione", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "songCount": "{count,plural,=1{{count} Brano} other{{count} Brani}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "stackTrace": "Stack Trace", + "@stackTrace": {}, + "customLocation": "Posizione Personalizzata", + "@customLocation": {}, + "dark": "Scuro", + "@dark": {}, + "createButtonLabel": "CREA", + "@createButtonLabel": {}, + "direct": "DIRETTO", + "@direct": {}, + "downloadedImagesCount": "{count,plural,=1{{count} immagine} other{{count} immagini}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsCount": "{count,plural,=1{{count} oggetto} other{{count} oggetti}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "customLocationsBuggy": "Le posizioni personalizzate sono estremamente instabili a causa di problemi con i permessi. Sto pensando a un modo di aggiustarle, ma per adesso non ne consiglio l'utilizzo.", + "@customLocationsBuggy": {}, + "removeFromMix": "Rimuovi dal Mix", + "@removeFromMix": {}, + "addToMix": "Aggiungi al Mix", + "@addToMix": {}, + "disableGestureSubtitle": "Se vuoi disabilitare i gesti.", + "@disableGestureSubtitle": {}, + "bufferDuration": "Durata Buffer", + "@bufferDuration": {}, + "disableGesture": "Disabilita gesti", + "@disableGesture": {}, + "minutes": "Minuti", + "@minutes": {}, + "bufferDurationSubtitle": "Quanto buffer, in secondi, deve avere il player. Richiede riavvio.", + "@bufferDurationSubtitle": {}, + "redownloadedItems": "{count,plural, =0{Non è necessario riscaricare nulla.} =1{Riscaricato {count} oggetto} other{Riscaricati {count} oggetti}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "removeFromPlaylistTooltip": "Rimuovi dalla playlist", + "@removeFromPlaylistTooltip": {}, + "removeFromPlaylistTitle": "Rimuovi dalla playlist", + "@removeFromPlaylistTitle": {}, + "removedFromPlaylist": "Rimosso dalla playlist.", + "@removedFromPlaylist": {}, + "language": "Lingua", + "@language": {}, + "showUncensoredLogMessage": "Questo log contiene le tue credenziali di accesso. Vuoi vederlo?", + "@showUncensoredLogMessage": {}, + "insertedIntoQueue": "Inserito nella coda.", + "@insertedIntoQueue": { + "description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end." + }, + "refresh": "AGGIORNA", + "@refresh": {}, + "confirm": "Conferma", + "@confirm": {}, + "playNext": "Suona il prossimo", + "@playNext": { + "description": "Popup menu item title for inserting an item into the play queue after the currently-playing item." + }, + "resetTabs": "Resettare le schede", + "@resetTabs": {}, + "noMusicLibrariesBody": "Finamp non è riuscito a trovare nessuna raccolta musicale. Sei pregato di controllare che il tuo server Jellyfin contenga almeno una raccolta con il genere configurato come \"Musica\".", + "@noMusicLibrariesBody": {}, + "noMusicLibrariesTitle": "Nessuna raccolta musicale", + "@noMusicLibrariesTitle": { + "description": "Title for message that shows on the views screen when no music libraries could be found." + }, + "syncDownloadedPlaylists": "Sincronizza playlists scaricate", + "@syncDownloadedPlaylists": {}, + "deleteDownloadsConfirmButtonText": "Elimina", + "@deleteDownloadsConfirmButtonText": { + "description": "Shown in the confirmation dialog for deleting downloaded media from the local device." + }, + "deleteDownloadsAbortButtonText": "Annulla", + "@deleteDownloadsAbortButtonText": {} +} diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb new file mode 100644 index 0000000..78b83cf --- /dev/null +++ b/lib/l10n/app_ja.arb @@ -0,0 +1,527 @@ +{ + "noErrors": "エラーなし!", + "@noErrors": {}, + "error": "エラー", + "@error": {}, + "serverUrl": "サーバー URL", + "@serverUrl": {}, + "internalExternalIpExplanation": "リモートからJellyfinサーバーにアクセスするには外部IPを指定する必要があります。\n\nサーバーがHTTPポート(80/443)で動いている場合にはポートは不要です。サーバーがリバースプロキシの後ろにある場合はこれが一般的です。", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "emptyServerUrl": "サーバーURLを入力してください", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "password": "パスワード", + "@password": {}, + "logs": "ログ", + "@logs": {}, + "username": "ユーザー名", + "@username": {}, + "next": "次", + "@next": {}, + "urlStartWithHttps": "URL は http:// か https:// で始まらなければなりません", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "urlTrailingSlash": "URL の末尾にスラッシュがあってはなりません", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "unknownName": "タイトル不明", + "@unknownName": {}, + "couldNotFindLibraries": "ライブラリが見つかりません。", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "songs": "曲", + "@songs": {}, + "albums": "アルバム", + "@albums": {}, + "artists": "アーティスト", + "@artists": {}, + "genres": "ジャンル", + "@genres": {}, + "selectMusicLibraries": "ミュージック・ライブラリを選択", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "playlists": "プレイリスト", + "@playlists": {}, + "music": "ミュージック", + "@music": {}, + "clear": "クリア", + "@clear": {}, + "shuffleAll": "全てシャッフル", + "@shuffleAll": {}, + "downloads": "ダウンロード", + "@downloads": {}, + "startMix": "ミックス開始", + "@startMix": {}, + "favourites": "お気に入り", + "@favourites": {}, + "startMixNoSongsArtist": "アーティスト名を長押しすることでミックス対象に追加または削除できます", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "startMixNoSongsAlbum": "アルバムを長押しすることでミックス対象に追加または削除できます", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "settings": "設定", + "@settings": {}, + "offlineMode": "オフライン・モード", + "@offlineMode": {}, + "sortOrder": "並び順", + "@sortOrder": {}, + "album": "アルバム", + "@album": {}, + "sortBy": "並び基準", + "@sortBy": {}, + "albumArtist": "アルバム・アーティスト", + "@albumArtist": {}, + "artist": "アーティスト", + "@artist": {}, + "budget": "予算", + "@budget": {}, + "productionYear": "作成年", + "@productionYear": {}, + "dateAdded": "追加日", + "@dateAdded": {}, + "playCount": "再生回数", + "@playCount": {}, + "datePlayed": "再生日", + "@datePlayed": {}, + "premiereDate": "公開日", + "@premiereDate": {}, + "criticRating": "評論家評価", + "@criticRating": {}, + "communityRating": "コミュニティ評価", + "@communityRating": {}, + "random": "ランダム", + "@random": {}, + "downloadErrors": "ダウンロード・エラー", + "@downloadErrors": {}, + "downloadMissingImages": "欠けている画像をダウンロード", + "@downloadMissingImages": {}, + "revenue": "収入", + "@revenue": {}, + "downloadCount": "{count,plural, =1{{count} 個ダウンロード} other{{count}個ダウンロード}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsCount": "{count,plural, =1{{count} アイテム} other{{count}アイテム}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedImagesCount": "{count,plural, =1{{count} 枚の画像} other{{count}枚の画像}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlComplete": "{count} 個完了", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "ダウンロード・エラー", + "@downloadErrorsTitle": {}, + "dlEnqueued": "{count} 個キューに追加", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "{count} 個再生中", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlFailed": "{count}個失敗", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "playButtonLabel": "再生", + "@playButtonLabel": {}, + "songCount": "{count,plural,=1{{count}曲} other{{count}曲}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "editPlaylistNameTooltip": "プレイリスト名を編集", + "@editPlaylistNameTooltip": {}, + "updateButtonLabel": "更新", + "@updateButtonLabel": {}, + "downloadsDeleted": "ダウンロードを削除しました。", + "@downloadsDeleted": {}, + "addButtonLabel": "追加", + "@addButtonLabel": {}, + "shareLogs": "ログ共有", + "@shareLogs": {}, + "location": "場所", + "@location": {}, + "favourite": "お気に入り", + "@favourite": {}, + "downloadsAdded": "ダウンロードを追加しました。", + "@downloadsAdded": {}, + "stackTrace": "スタックトレース", + "@stackTrace": {}, + "transcoding": "トランスコード", + "@transcoding": {}, + "layoutAndTheme": "レイアウト&テーマ", + "@layoutAndTheme": {}, + "notAvailableInOfflineMode": "オフライン・モードでは無効", + "@notAvailableInOfflineMode": {}, + "downloadLocations": "ダウンロード場所", + "@downloadLocations": {}, + "audioService": "オーディオ・サービス", + "@audioService": {}, + "logOut": "ログアウト", + "@logOut": {}, + "downloadedSongsWillNotBeDeleted": "ダウロード済みの曲は削除されません", + "@downloadedSongsWillNotBeDeleted": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin はトランスコードに AAC を利用します", + "@jellyfinUsesAACForTranscoding": {}, + "enableTranscoding": "トランスコード有効", + "@enableTranscoding": {}, + "bitrate": "ビットレート", + "@bitrate": {}, + "addDownloadLocation": "ダウンロード場所を追加", + "@addDownloadLocation": {}, + "bitrateSubtitle": "高いビットレートではオーディオの質が高くなりますが、転送容量も高くなります。", + "@bitrateSubtitle": {}, + "selectDirectory": "ディレクトリを選択", + "@selectDirectory": {}, + "unknownError": "不明なエラー", + "@unknownError": {}, + "pathReturnSlashErrorMessage": "\"/\" を返すパスは利用できません", + "@pathReturnSlashErrorMessage": {}, + "directoryMustBeEmpty": "ディレクトリは空でなければなりません", + "@directoryMustBeEmpty": {}, + "list": "リスト", + "@list": {}, + "grid": "グリッド", + "@grid": {}, + "portrait": "縦表示", + "@portrait": {}, + "landscape": "横表示", + "@landscape": {}, + "enterLowPriorityStateOnPause": "一時停止時は低優先度状態", + "@enterLowPriorityStateOnPause": {}, + "showTextOnGridView": "グリッド・ビューでテキストを表示する", + "@showTextOnGridView": {}, + "showCoverAsPlayerBackground": "プレイヤー背景にジャケット画像をぼかして表示する", + "@showCoverAsPlayerBackground": {}, + "showTextOnGridViewSubtitle": "グリッド音楽画面でテキスト(タイトル、アーティスト等)を表示させるか。", + "@showTextOnGridViewSubtitle": {}, + "showCoverAsPlayerBackgroundSubtitle": "プレイヤー画面で背景にジャケット画像をぼかして表示させるか。", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "theme": "テーマ", + "@theme": {}, + "system": "システム", + "@system": {}, + "disableGesture": "ジェスチャーを無効", + "@disableGesture": {}, + "light": "明るい", + "@light": {}, + "disableGestureSubtitle": "ジェスチャーを無効にするか。", + "@disableGestureSubtitle": {}, + "tabs": "タブ", + "@tabs": {}, + "invalidNumber": "無効な数値", + "@invalidNumber": {}, + "sleepTimerTooltip": "スリープ・タイマー", + "@sleepTimerTooltip": {}, + "addToPlaylistTooltip": "プレイリストに追加", + "@addToPlaylistTooltip": {}, + "dark": "暗い", + "@dark": {}, + "cancelSleepTimer": "スリープ・タイマーをキャンセルしますか?", + "@cancelSleepTimer": {}, + "yesButtonLabel": "はい", + "@yesButtonLabel": {}, + "noButtonLabel": "いいえ", + "@noButtonLabel": {}, + "setSleepTimer": "スリープ・タイマーを設定", + "@setSleepTimer": {}, + "minutes": "分", + "@minutes": {}, + "addToPlaylistTitle": "プレイリストに追加", + "@addToPlaylistTitle": {}, + "addedToQueue": "キューに追加。", + "@addedToQueue": {}, + "queueReplaced": "キューを置き換えました。", + "@queueReplaced": {}, + "addToMix": "ミックスに追加", + "@addToMix": {}, + "removeFromMix": "ミックスから外す", + "@removeFromMix": {}, + "enableTranscodingSubtitle": "サーバー側の音楽ストリームをトランスコードします。", + "@enableTranscodingSubtitle": {}, + "hideSongArtistsIfSameAsAlbumArtists": "曲のアーティストがアルバム・アーティストと同じの場合、表示しない", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "newPlaylist": "新規プレイリスト", + "@newPlaylist": {}, + "removeFromPlaylistTooltip": "プレイリストから外す", + "@removeFromPlaylistTooltip": {}, + "removeFromPlaylistTitle": "プレイリストから外す", + "@removeFromPlaylistTitle": {}, + "createButtonLabel": "作成", + "@createButtonLabel": {}, + "playlistCreated": "プレイリストを作成しました。", + "@playlistCreated": {}, + "noAlbum": "アルバム無し", + "@noAlbum": {}, + "noItem": "アイテム無し", + "@noItem": {}, + "noArtist": "アーティスト無し", + "@noArtist": {}, + "unknownArtist": "アーティスト不明", + "@unknownArtist": {}, + "streaming": "ストリーミング", + "@streaming": {}, + "downloaded": "ダウンロード済み", + "@downloaded": {}, + "transcode": "トランスコード", + "@transcode": {}, + "statusError": "ステータス・エラー", + "@statusError": {}, + "removeFavourite": "お気に入りを削除", + "@removeFavourite": {}, + "addFavourite": "お気に入りを追加", + "@addFavourite": {}, + "removedFromPlaylist": "プレイリストから外しました。", + "@removedFromPlaylist": {}, + "startingInstantMix": "インスタント・ミックス開始。", + "@startingInstantMix": {}, + "anErrorHasOccured": "エラーが発生しました。", + "@anErrorHasOccured": {}, + "instantMix": "インスタント・ミックス", + "@instantMix": {}, + "direct": "直接", + "@direct": {}, + "queue": "キュー", + "@queue": {}, + "addToQueue": "キューに追加", + "@addToQueue": {}, + "replaceQueue": "キューを置き換え", + "@replaceQueue": {}, + "responseError": "{error} ステータスコード {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "required": "必須", + "@required": {}, + "discNumber": "ディスク {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "shuffleButtonLabel": "シャッフル", + "@shuffleButtonLabel": {}, + "editPlaylistNameTitle": "プレイリスト名を編集", + "@editPlaylistNameTitle": {}, + "playlistNameUpdated": "プレイリスト名を更新しました。", + "@playlistNameUpdated": {}, + "addDownloads": "ダウンロードを追加", + "@addDownloads": {}, + "logsCopied": "ログをコピーしました。", + "@logsCopied": {}, + "message": "メッセージ", + "@message": {}, + "failedToGetSongFromDownloadId": "ダウンロード ID から曲が取得できません", + "@failedToGetSongFromDownloadId": {}, + "downloadedMissingImages": "{count,plural, =0{欠けている画像が見つかりません} =1{欠けている画像を {count} 枚ダウンロード} other{欠けている画像を {count} 枚ダウンロード}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "goToAlbum": "アルバムに行く", + "@goToAlbum": {}, + "errorScreenError": "エラーリスト取得時にエラーが発生しました!この時点では、GitHub で issue を作成し、アプリデータを削除することをお勧めします", + "@errorScreenError": {}, + "startupError": "起動時に問題が起こりました。エラー内容: {error}\n\ngithub.com/UnicornsOnLSD/finamp でイシューを作成し、このページのスクリーンショットを付けてください。問題が継続した場合はアプリデータをクリアしてアプリを初期化して下さい。", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "曲のアーティストがアルバムのアーティストと一致した場合、アルバム画面に表示させるか。", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "enterLowPriorityStateOnPauseSubtitle": "一時停止時に通知をスワイプで除去できます。また Android では一時停止時にサービスを停止できます。", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "gridCrossAxisCountSubtitle": "{value}の場合一行当たりのグリッドタイル数。", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "bufferDurationSubtitle": "プレイヤーがバッファする容量(秒単位)。リスタートが必要です。", + "@bufferDurationSubtitle": {}, + "runtime": "再生時間", + "@runtime": {}, + "name": "曲名", + "@name": {}, + "bufferDuration": "バッファ容量", + "@bufferDuration": {}, + "language": "言語", + "@language": {}, + "finamp": "Finamp", + "@finamp": {}, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "areYouSure": "よろしいですか?", + "@areYouSure": {}, + "appDirectory": "Appのディレクトリ", + "@appDirectory": {}, + "shuffleAllSongCount": "曲がシャッフル化される回数", + "@shuffleAllSongCount": {}, + "shuffleAllSongCountSubtitle": "すべての曲をシャッフルする時、ここで指定した曲数をロードします。", + "@shuffleAllSongCountSubtitle": {}, + "viewType": "ビューの形式", + "@viewType": {}, + "gridCrossAxisCount": "{value} のアイテム数", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "customLocation": "指定の場所", + "@customLocation": {}, + "refresh": "リフレッシュ", + "@refresh": {}, + "confirm": "確認する", + "@confirm": {}, + "playNext": "再生次", + "@playNext": { + "description": "Popup menu item title for inserting an item into the play queue after the currently-playing item." + }, + "resetTabs": "タブリセット", + "@resetTabs": {}, + "viewTypeSubtitle": "ミュージック画面の表示形式", + "@viewTypeSubtitle": {}, + "deleteDownloadsPrompt": "このデバイスから{itemType, select, album{album} playlist{playlist} artist{artist} genre{genre} track{song} other{}} '{itemName}'を削除してもよろしいですか?", + "@deleteDownloadsPrompt": { + "placeholders": { + "itemName": { + "type": "String", + "example": "Abandon Ship" + }, + "itemType": { + "type": "String", + "example": "album" + } + }, + "description": "Confirmation prompt shown before deleting downloaded media from the local device, destructive action, doesn't affect the media on the server." + }, + "insertedIntoQueue": "キューに挿入されました。", + "@insertedIntoQueue": { + "description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end." + }, + "responseError401": "{error} Status code {statusCode}. ユーザー名/パスワードが間違っているか、クライアントがログインしていません。", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "noMusicLibrariesBody": "Finamp は音楽ライブラリを見つけることができませんでした。Jellyfin サーバーに、コンテンツ タイプが「音楽」に設定されているライブラリが少なくとも 1 つ含まれていることを確認してください。", + "@noMusicLibrariesBody": {}, + "swipeInsertQueueNextSubtitle": "曲リストでスワイプしたときに、曲を最後に追加するのではなく、キューの次の項目として挿入できるようにします。", + "@swipeInsertQueueNextSubtitle": {}, + "deleteDownloadsAbortButtonText": "キャンセル", + "@deleteDownloadsAbortButtonText": {}, + "showUncensoredLogMessage": "このログはあなたのログイン情報を含みます。表示しますか?", + "@showUncensoredLogMessage": {}, + "syncDownloadedPlaylists": "ダウンロードしたプレイリストの同期", + "@syncDownloadedPlaylists": {}, + "deleteDownloadsConfirmButtonText": "削除", + "@deleteDownloadsConfirmButtonText": { + "description": "Shown in the confirmation dialog for deleting downloaded media from the local device." + }, + "applicationLegalese": "Mozilla Public License 2.0 でライセンスされています。ソース コードは以下から入手できます:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "customLocationsBuggy": "カスタムの場所は、権限の問題により、非常にバグが多くなります。これを修正する方法を考えていますが、今のところは使用しないことをお勧めします。", + "@customLocationsBuggy": {}, + "showFastScroller": "高速スクロールを表示", + "@showFastScroller": {}, + "swipeInsertQueueNext": "スワイプした曲を再生する", + "@swipeInsertQueueNext": {}, + "noMusicLibrariesTitle": "音楽ライブラリなし", + "@noMusicLibrariesTitle": { + "description": "Title for message that shows on the views screen when no music libraries could be found." + } +} diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb new file mode 100644 index 0000000..a08151b --- /dev/null +++ b/lib/l10n/app_ko.arb @@ -0,0 +1,594 @@ +{ + "internalExternalIpExplanation": "귀하의 Jellyfin 서버에 원격으로 접속하려면, 외부 IP 주소를 사용해야 합니다.\n\n귀하의 서버가 HTTP 포트(80/443)에 있거나 역방향 프록시(Reverse Proxy) 뒤에 있는 경우, 포트를 지정할 필요는 없습니다.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "next": "다음", + "@next": {}, + "selectMusicLibraries": "음악 라이브러리 선택", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "genres": "장르", + "@genres": {}, + "startMix": "믹스 시작하기", + "@startMix": {}, + "serverUrl": "서버 URL", + "@serverUrl": {}, + "emptyServerUrl": "서버 URL은 필수값 입니다", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "urlStartWithHttps": "URL은 \"http://\" 또는 \"https://\"로 시작해야 합니다", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "urlTrailingSlash": "URL 끝부분에 슬래시(/, 후행 슬래시)를 붙이지 마세요", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "username": "사용자 이름(아이디)", + "@username": {}, + "password": "암호", + "@password": {}, + "logs": "로그(사용기록)", + "@logs": {}, + "couldNotFindLibraries": "라이브러리를 찾을 수 없습니다.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "unknownName": "알 수 없는 이름", + "@unknownName": {}, + "songs": "노래", + "@songs": {}, + "albums": "앨범", + "@albums": {}, + "artists": "아티스트", + "@artists": {}, + "playlists": "플레이리스트", + "@playlists": {}, + "startMixNoSongsAlbum": "믹스를 시작하기 전에 '앨범'을 길게 탭하여 믹스 빌더에서 추가하거나 제거하세요", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "music": "음악", + "@music": {}, + "clear": "삭제(비우기)", + "@clear": {}, + "shuffleAll": "임의 재생(모두)", + "@shuffleAll": {}, + "finamp": "핀앰프(Finamp)", + "@finamp": {}, + "sortOrder": "정렬 순서", + "@sortOrder": {}, + "sortBy": "정렬 기준", + "@sortBy": {}, + "budget": "예산", + "@budget": {}, + "communityRating": "커뮤니티 평점", + "@communityRating": {}, + "criticRating": "비평가 평점", + "@criticRating": {}, + "dateAdded": "추가된 날짜", + "@dateAdded": {}, + "datePlayed": "재생한 날짜", + "@datePlayed": {}, + "premiereDate": "초연(프리미어) 날짜", + "@premiereDate": {}, + "productionYear": "제작년도", + "@productionYear": {}, + "name": "이름", + "@name": {}, + "random": "랜덤", + "@random": {}, + "revenue": "수익", + "@revenue": {}, + "runtime": "런타임(상영 시간)", + "@runtime": {}, + "syncDownloadedPlaylists": "다운로드한 플레이리스트 동기화", + "@syncDownloadedPlaylists": {}, + "downloadMissingImages": "누락된 이미지 다운로드", + "@downloadMissingImages": {}, + "downloadErrors": "다운로드 에러", + "@downloadErrors": {}, + "downloadCount": "{count,plural, =1{{count}건 다운로드} other{{count}건 다운로드}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsCount": "{count,plural,=1{{count} 아이템} other{{count} 아이템}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlComplete": "{count}건 완료", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlFailed": "{count}건 실패", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlEnqueued": "{count}건 대기열에 추가됨", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "{count}건 진행중", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "다운로드 오류", + "@downloadErrorsTitle": {}, + "noErrors": "오류가 없습니다!", + "@noErrors": {}, + "failedToGetSongFromDownloadId": "다운로드 ID에서 노래를 가져오지 못했습니다", + "@failedToGetSongFromDownloadId": {}, + "deleteDownloadsPrompt": "이 기기에서 {itemType, select, album{album} playlist{playlist} artist{artist} genre{genre} track{song} other{}} '{itemName}'를 삭제하시겠습니까?", + "@deleteDownloadsPrompt": { + "placeholders": { + "itemName": { + "type": "String", + "example": "Abandon Ship" + }, + "itemType": { + "type": "String", + "example": "album" + } + }, + "description": "Confirmation prompt shown before deleting downloaded media from the local device, destructive action, doesn't affect the media on the server." + }, + "deleteDownloadsConfirmButtonText": "삭제", + "@deleteDownloadsConfirmButtonText": { + "description": "Shown in the confirmation dialog for deleting downloaded media from the local device." + }, + "playButtonLabel": "재생", + "@playButtonLabel": {}, + "shuffleButtonLabel": "임의 재생", + "@shuffleButtonLabel": {}, + "songCount": "{count,plural,=1{{count} 곡} other{{count} 곡}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "editPlaylistNameTooltip": "플레이리스트 이름 수정", + "@editPlaylistNameTooltip": {}, + "required": "필수 항목", + "@required": {}, + "updateButtonLabel": "업데이트", + "@updateButtonLabel": {}, + "favourite": "즐겨찾기", + "@favourite": {}, + "addDownloads": "다운로드 추가", + "@addDownloads": {}, + "location": "위치", + "@location": {}, + "shareLogs": "로그(사용기록) 공유", + "@shareLogs": {}, + "logsCopied": "로그(사용기록) 복사함.", + "@logsCopied": {}, + "message": "메시지", + "@message": {}, + "stackTrace": "스택 추적", + "@stackTrace": {}, + "applicationLegalese": "Mozilla Public License 2.0에 따라 라이선스가 부여됐습니다. 소스 코드는 다음에서 확인할 수 있습니다:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "transcoding": "트랜스코딩", + "@transcoding": {}, + "downloadLocations": "다운로드 위치", + "@downloadLocations": {}, + "interactions": "상호작용", + "@interactions": {}, + "layoutAndTheme": "레이아웃 & 테마", + "@layoutAndTheme": {}, + "downloadedSongsWillNotBeDeleted": "다운로드한 노래는 삭제되지 않습니다", + "@downloadedSongsWillNotBeDeleted": {}, + "areYouSure": "확실한가요?", + "@areYouSure": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin은 트랜스코딩에 AAC를 사용합니다", + "@jellyfinUsesAACForTranscoding": {}, + "enableTranscoding": "트랜스코딩 활성화", + "@enableTranscoding": {}, + "enableTranscodingSubtitle": "서버 측에서 음악 스트리밍을 트랜스코딩 합니다.", + "@enableTranscodingSubtitle": {}, + "bitrate": "비트레이트", + "@bitrate": {}, + "customLocation": "사용자 지정 위치", + "@customLocation": {}, + "unknownError": "알수 없는 오류", + "@unknownError": {}, + "directoryMustBeEmpty": "디렉토리는 비어 있어야 합니다", + "@directoryMustBeEmpty": {}, + "shuffleAllSongCount": "전곡 임의 재생시 곡 수", + "@shuffleAllSongCount": {}, + "shuffleAllSongCountSubtitle": "'전곡 임의 재생' 버튼을 사용할 때 불러올 곡의 개수입니다.", + "@shuffleAllSongCountSubtitle": {}, + "viewTypeSubtitle": "음악 화면 보기 유형", + "@viewTypeSubtitle": {}, + "list": "목록", + "@list": {}, + "grid": "그리드(격자)", + "@grid": {}, + "portrait": "세로 보기", + "@portrait": {}, + "landscape": "가로 보기", + "@landscape": {}, + "gridCrossAxisCount": "{value} 그리드 열 갯수", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "showTextOnGridView": "그리드 보기에서 텍스트를 보여줌", + "@showTextOnGridView": {}, + "showCoverAsPlayerBackground": "흐릿한 커버를 재생 화면 배경으로 보여줌", + "@showCoverAsPlayerBackground": {}, + "hideSongArtistsIfSameAsAlbumArtists": "앨범 아티스트와 동일한 경우, 곡 아티스트를 숨김", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "showTextOnGridViewSubtitle": "그리드 음악 화면에서 '텍스트(곡목, 아티스트 등)' 표시 여부를 설정합니다.", + "@showTextOnGridViewSubtitle": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "앨범 화면에서 앨범 아티스트와 동일한 '곡 아티스트' 표시 여부를 설정합니다.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "disableGesture": "제스처 비활성화", + "@disableGesture": {}, + "disableGestureSubtitle": "제스처 비활성화 여부를 설정합니다.", + "@disableGestureSubtitle": {}, + "showFastScroller": "빠른 스크롤 표시", + "@showFastScroller": {}, + "theme": "테마", + "@theme": {}, + "system": "시스템", + "@system": {}, + "light": "밝은 테마", + "@light": {}, + "tabs": "탭", + "@tabs": {}, + "yesButtonLabel": "네", + "@yesButtonLabel": {}, + "noButtonLabel": "아니오", + "@noButtonLabel": {}, + "setSleepTimer": "취침 타이머 설정", + "@setSleepTimer": {}, + "minutes": "분", + "@minutes": {}, + "invalidNumber": "잘못된 숫자", + "@invalidNumber": {}, + "addToPlaylistTooltip": "플레이리스트에 추가", + "@addToPlaylistTooltip": {}, + "addToPlaylistTitle": "플레이리스트에 추가", + "@addToPlaylistTitle": {}, + "removeFromPlaylistTooltip": "플레이리스트에서 삭제", + "@removeFromPlaylistTooltip": {}, + "newPlaylist": "새 플레이리스트", + "@newPlaylist": {}, + "createButtonLabel": "만들기", + "@createButtonLabel": {}, + "noAlbum": "앨범 없음", + "@noAlbum": {}, + "noItem": "곡 없음", + "@noItem": {}, + "noArtist": "아티스트 없음", + "@noArtist": {}, + "unknownArtist": "알 수 없는 아티스트", + "@unknownArtist": {}, + "streaming": "스트리밍", + "@streaming": {}, + "downloaded": "다운로드됨", + "@downloaded": {}, + "transcode": "트랜스코딩", + "@transcode": {}, + "statusError": "상태 오류", + "@statusError": {}, + "queue": "대기열", + "@queue": {}, + "addToQueue": "대기열에 추가", + "@addToQueue": { + "description": "Popup menu item title for adding an item to the end of the play queue." + }, + "instantMix": "인스턴트 믹스", + "@instantMix": {}, + "goToAlbum": "앨범으로 이동", + "@goToAlbum": {}, + "addFavourite": "즐겨찾기 추가", + "@addFavourite": {}, + "insertedIntoQueue": "대기열 중간에 추가.", + "@insertedIntoQueue": { + "description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end." + }, + "queueReplaced": "대기열이 교체됨.", + "@queueReplaced": {}, + "startingInstantMix": "인스턴트 믹스 시작.", + "@startingInstantMix": {}, + "anErrorHasOccured": "오류가 발생했습니다.", + "@anErrorHasOccured": {}, + "responseError": "{error} 상태 코드 {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "responseError401": "{error} 상태 코드 {statusCode}. 이것은 아마도 잘못된 로그인 정보를 사용했거나, 더 이상 로그인되어 있지 않음을 의미합니다.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "removeFromMix": "믹스에서 삭제", + "@removeFromMix": {}, + "addToMix": "믹스에 추가", + "@addToMix": {}, + "redownloadedItems": "{count,plural, =0{다시 다운로드할 필요가 없습니다.} =1{{count} 아이템 다시 다운로드함} other{{count} 아이템 다시 다운로드함}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "bufferDuration": "버퍼 시간", + "@bufferDuration": {}, + "bufferDurationSubtitle": "플레이어가 버퍼링해야 하는 시간(초) 입니다. 다시 시작해야 합니다.", + "@bufferDurationSubtitle": {}, + "language": "언어", + "@language": {}, + "showUncensoredLogMessage": "이 로그(사용기록)는 귀하의 로그인 정보를 포함합니다. 표시할까요?", + "@showUncensoredLogMessage": {}, + "resetTabs": "탭 초기화", + "@resetTabs": {}, + "noMusicLibrariesTitle": "음악 라이브러리 없음", + "@noMusicLibrariesTitle": { + "description": "Title for message that shows on the views screen when no music libraries could be found." + }, + "noMusicLibrariesBody": "Finamp가 음악 라이브러리를 찾을 수 없습니다. 귀하의 Jellyfin 서버에 콘텐츠 유형이 \"음악\"으로 설정된 라이브러리가 하나 이상 있는지 확인하세요.", + "@noMusicLibrariesBody": {}, + "startMixNoSongsArtist": "믹스를 시작하기 전에 '아티스트'를 길게 탭하여 믹스 빌더에서 추가하거나 제거하세요", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "playCount": "재생 횟수", + "@playCount": {}, + "downloadedMissingImages": "{count,plural, =0{누락된 이미지 없음} =1{누락된 이미지 {count}건 다운로드} other{누락된 이미지 {count}건 다운로드}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "favourites": "즐겨찾기", + "@favourites": {}, + "album": "앨범", + "@album": {}, + "downloads": "다운로드", + "@downloads": {}, + "settings": "설정", + "@settings": {}, + "offlineMode": "오프라인 모드", + "@offlineMode": {}, + "albumArtist": "앨범 아티스트", + "@albumArtist": {}, + "artist": "아티스트", + "@artist": {}, + "downloadedImagesCount": "{count,plural,=1{이미지 {count}건} other{이미지 {count}건}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "refresh": "새로고침", + "@refresh": {}, + "redesignBeta": "베타 버전 사용해보기", + "@redesignBeta": {}, + "errorScreenError": "오류 목록을 가져오지 못했습니다! 이 시점에서는 GitHub에 문제를 등록하고 앱 데이터를 삭제해야 합니다", + "@errorScreenError": {}, + "deleteDownloadsAbortButtonText": "취소", + "@deleteDownloadsAbortButtonText": {}, + "error": "오류", + "@error": {}, + "discNumber": "디스크 {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "downloadsDeleted": "다운로드 항목을 삭제했습니다.", + "@downloadsDeleted": {}, + "editPlaylistNameTitle": "플레이리스트 이름 수정", + "@editPlaylistNameTitle": {}, + "playlistNameUpdated": "플레이리스트 이름을 갱신했습니다.", + "@playlistNameUpdated": {}, + "downloadsAdded": "다운로드를 추가했습니다.", + "@downloadsAdded": {}, + "addButtonLabel": "추가", + "@addButtonLabel": {}, + "audioService": "오디오 서비스", + "@audioService": {}, + "notAvailableInOfflineMode": "오프라인 모드에서는 사용할 수 없습니다", + "@notAvailableInOfflineMode": {}, + "logOut": "로그아웃", + "@logOut": {}, + "bitrateSubtitle": "비트레이트가 높으면 고품질의 음악을 들을 수 있습니다. 데이터 사용량도 증가합니다.", + "@bitrateSubtitle": {}, + "appDirectory": "앱 디렉토리", + "@appDirectory": {}, + "addDownloadLocation": "다운로드 위치 추가", + "@addDownloadLocation": {}, + "selectDirectory": "디렉토리 선택", + "@selectDirectory": {}, + "pathReturnSlashErrorMessage": "\"/(슬래시)\"로 끝나는 경로는 사용할 수 없습니다", + "@pathReturnSlashErrorMessage": {}, + "gridCrossAxisCountSubtitle": "{value} 일때 한 행당 그리드 타일의 개수.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "showCoverAsPlayerBackgroundSubtitle": "재생 화면에서 '흐릿한 앨범 커버 배경' 사용 여부를 설정합니다.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "customLocationsBuggy": "사용자 지정 위치는 권한 문제 때문에 버그가 매우 많습니다. 이 문제를 해결할 방법을 찾을 때 까지는 사용을 권장하지 않습니다.", + "@customLocationsBuggy": {}, + "enterLowPriorityStateOnPause": "일시 중지시 낮은 우선순위 상태로 전환합니다", + "@enterLowPriorityStateOnPause": {}, + "enterLowPriorityStateOnPauseSubtitle": "일시 중지시 알림창을 밀어서 사라지게 합니다. 안드로이드(OS)에서는 일시 중지시 서비스를 강제 종료할 수 있게 합니다.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "viewType": "보기 유형", + "@viewType": {}, + "dark": "어두운 테마", + "@dark": {}, + "sleepTimerTooltip": "취침 타이머", + "@sleepTimerTooltip": {}, + "cancelSleepTimer": "취침 타이머를 취소할까요?", + "@cancelSleepTimer": {}, + "removeFromPlaylistTitle": "플레이리스트에서 삭제", + "@removeFromPlaylistTitle": {}, + "playlistCreated": "플레이리스트 생성됨.", + "@playlistCreated": {}, + "direct": "다이렉트", + "@direct": {}, + "replaceQueue": "대기열 교체", + "@replaceQueue": {}, + "addedToQueue": "대기열 끝에 추가.", + "@addedToQueue": { + "description": "Snackbar message that shows when the user successfully adds items to the end of the play queue." + }, + "removedFromPlaylist": "플레이리스트에서 삭제됨.", + "@removedFromPlaylist": {}, + "playNext": "다음 곡 재생", + "@playNext": { + "description": "Popup menu item title for inserting an item into the play queue after the currently-playing item." + }, + "removeFavourite": "즐겨찾기 삭제", + "@removeFavourite": {}, + "confirm": "확인", + "@confirm": {}, + "swipeInsertQueueNext": "스와이프한 노래 재생", + "@swipeInsertQueueNext": {}, + "swipeInsertQueueNextSubtitle": "노래 목록에서 스와이프 했을 때, 노래를 대기열 끝에 추가하는 대신 바로 다음 곡으로 삽입할 수 있습니다.", + "@swipeInsertQueueNextSubtitle": {}, + "startupError": "앱 시작중에 문제가 발생했습니다. 오류 내용:{error}\n\n\"github.com/UnicornsOnLSD/finamp\"에 이 페이지의 캡처 화면을 첨부하여 '이슈'를 등록 해주세요. 이 문제가 지속되면 당신은 앱 데이터를 삭제하여 앱을 초기화 할 수 있습니다.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "playbackOrderShuffledTooltip": "임의 재생. 전환하려면 탭하세요.", + "@playbackOrderShuffledTooltip": {}, + "playbackOrderLinearTooltip": "순차 재생. 전환하려면 탭하세요.", + "@playbackOrderLinearTooltip": {}, + "loopModeAllTooltip": "모두 반복. 전환하려면 탭하세요.", + "@loopModeAllTooltip": {}, + "loopModeOneTooltip": "한곡 반복. 전환하려면 탭하세요.", + "@loopModeOneTooltip": {}, + "loopModeNoneTooltip": "반복 안 함. 전환하려면 탭하세요.", + "@loopModeNoneTooltip": {}, + "skipToPrevious": "이전 곡으로 넘기기", + "@skipToPrevious": {}, + "skipToNext": "다음 곡으로 넘기기", + "@skipToNext": {}, + "togglePlayback": "재생 전환하기", + "@togglePlayback": {}, + "playArtist": "{artist}의 모든 앨범 재생", + "@playArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "shuffleArtist": "{artist}의 모든 앨범 임의 재생", + "@shuffleArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "downloadArtist": "{artist}의 모든 앨범 다운로드", + "@downloadArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "deleteFromDevice": "기기에서 삭제하기", + "@deleteFromDevice": {}, + "download": "다운로드", + "@download": {}, + "sync": "서버와 동기화하기", + "@sync": {}, + "about": "Finamp(핀앰프) 소개", + "@about": {} +} diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb new file mode 100644 index 0000000..c536343 --- /dev/null +++ b/lib/l10n/app_nb.arb @@ -0,0 +1,507 @@ +{ + "next": "Neste", + "@next": {}, + "selectMusicLibraries": "Velg musikkbibliotek", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "couldNotFindLibraries": "Fant ikke noen bibliotek.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "unknownName": "Ukjent navn", + "@unknownName": {}, + "album": "Album", + "@album": {}, + "albumArtist": "Albumsartist", + "@albumArtist": {}, + "finamp": "Finamp", + "@finamp": {}, + "downloads": "Nedlastninger", + "@downloads": {}, + "settings": "Innstillinger", + "@settings": {}, + "sortBy": "Sorter etter", + "@sortBy": {}, + "playCount": "Avspillingsantall", + "@playCount": {}, + "premiereDate": "Premieredato", + "@premiereDate": {}, + "productionYear": "Produksjonsår", + "@productionYear": {}, + "runtime": "Lengde", + "@runtime": {}, + "downloadErrorsTitle": "Nedlastingsfeil", + "@downloadErrorsTitle": {}, + "required": "Påkrevd", + "@required": {}, + "shareLogs": "Del logger", + "@shareLogs": {}, + "logsCopied": "Logger kopiert", + "@logsCopied": {}, + "logOut": "Logg ut", + "@logOut": {}, + "downloadedSongsWillNotBeDeleted": "Nedlastede spor vil ikke slettes", + "@downloadedSongsWillNotBeDeleted": {}, + "enableTranscoding": "Skru på transkoding", + "@enableTranscoding": {}, + "bitrate": "Bitrate", + "@bitrate": {}, + "unknownError": "Ukjent feil", + "@unknownError": {}, + "responseError": "{error} Statuskode {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "invalidNumber": "Ugyldig tall", + "@invalidNumber": {}, + "sleepTimerTooltip": "Søvntidsur", + "@sleepTimerTooltip": {}, + "addToPlaylistTooltip": "Legg til i spilleliste", + "@addToPlaylistTooltip": {}, + "createButtonLabel": "Opprett", + "@createButtonLabel": {}, + "noAlbum": "Ingen album", + "@noAlbum": {}, + "noItem": "Ingen elementer", + "@noItem": {}, + "noArtist": "Ingen artister", + "@noArtist": {}, + "unknownArtist": "Ukjent artist", + "@unknownArtist": {}, + "downloaded": "Nedlastet", + "@downloaded": {}, + "statusError": "Statusfeil", + "@statusError": {}, + "addToQueue": "Legg til i kø", + "@addToQueue": {}, + "replaceQueue": "Erstatt kø", + "@replaceQueue": {}, + "goToAlbum": "Gå til album", + "@goToAlbum": {}, + "removeFavourite": "Fjern favoritt", + "@removeFavourite": {}, + "addFavourite": "Legg til favoritt", + "@addFavourite": {}, + "addedToQueue": "Lagt til i kø.", + "@addedToQueue": {}, + "queueReplaced": "Kø erstattet.", + "@queueReplaced": {}, + "anErrorHasOccured": "Noe gikk galt.", + "@anErrorHasOccured": {}, + "removeFromMix": "Fjern fra miks", + "@removeFromMix": {}, + "addButtonLabel": "Legg til", + "@addButtonLabel": {}, + "playlistNameUpdated": "Spillelistenavn oppdatert.", + "@playlistNameUpdated": {}, + "criticRating": "Kritikervurdering", + "@criticRating": {}, + "downloadErrors": "Nedlastingsfeil", + "@downloadErrors": {}, + "artist": "Artist", + "@artist": {}, + "budget": "Budsjett", + "@budget": {}, + "artists": "Artister", + "@artists": {}, + "communityRating": "Gemenskapsvurdering", + "@communityRating": {}, + "downloadMissingImages": "Last ned manglende bilder", + "@downloadMissingImages": {}, + "startMix": "Start miks", + "@startMix": {}, + "random": "Tilfeldig", + "@random": {}, + "dateAdded": "Dato lagt til", + "@dateAdded": {}, + "datePlayed": "Dato avspilt", + "@datePlayed": {}, + "songs": "Spor", + "@songs": {}, + "genres": "Sjangere", + "@genres": {}, + "albums": "Album", + "@albums": {}, + "clear": "Tøm", + "@clear": {}, + "favourites": "Favoritter", + "@favourites": {}, + "editPlaylistNameTitle": "Rediger spillelistenavn", + "@editPlaylistNameTitle": {}, + "downloadsDeleted": "Nedlastinger slettet.", + "@downloadsDeleted": {}, + "audioService": "Lydtjeneste", + "@audioService": {}, + "areYouSure": "Er du sikker?", + "@areYouSure": {}, + "playlists": "Spillelister", + "@playlists": {}, + "music": "Musikk", + "@music": {}, + "offlineMode": "Frakoblet modus", + "@offlineMode": {}, + "sortOrder": "Sorteringsrekkefølge", + "@sortOrder": {}, + "name": "Navn", + "@name": {}, + "updateButtonLabel": "Oppdater", + "@updateButtonLabel": {}, + "noButtonLabel": "Nei", + "@noButtonLabel": {}, + "message": "Melding", + "@message": {}, + "notAvailableInOfflineMode": "Ikke tilgjengelig i frakoblet modus", + "@notAvailableInOfflineMode": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin bruker AAC for transkoding", + "@jellyfinUsesAACForTranscoding": {}, + "list": "Liste", + "@list": {}, + "light": "Lys", + "@light": {}, + "tabs": "Faner", + "@tabs": {}, + "cancelSleepTimer": "Avbryt søvntidsur?", + "@cancelSleepTimer": {}, + "grid": "Rutenett", + "@grid": {}, + "dark": "Mørk", + "@dark": {}, + "system": "System", + "@system": {}, + "yesButtonLabel": "Ja", + "@yesButtonLabel": {}, + "setSleepTimer": "Sett søvntidsur", + "@setSleepTimer": {}, + "newPlaylist": "Ny spilleliste", + "@newPlaylist": {}, + "playlistCreated": "Spilleliste opprettet.", + "@playlistCreated": {}, + "addToPlaylistTitle": "Legg til i spilleliste", + "@addToPlaylistTitle": {}, + "addToMix": "Legg til i miks", + "@addToMix": {}, + "minutes": "Minutter", + "@minutes": {}, + "transcode": "Transkod", + "@transcode": {}, + "streaming": "Strømming", + "@streaming": {}, + "queue": "Kø", + "@queue": {}, + "instantMix": "Umiddelbar miks", + "@instantMix": {}, + "direct": "Direkte", + "@direct": {}, + "startingInstantMix": "Starter umiddelbar miks …", + "@startingInstantMix": {}, + "responseError401": "{error} Statuskode {statusCode}. Dette betyr antagelig at du bruker feil brukernavn eller passord, eller at klienten din ikke lenger er innloget.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "dlRunning": "{count} kjører", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "urlStartWithHttps": "Nettadressen må starte med http:// eller https://", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "dlComplete": "{count} fullført", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlFailed": "{count} mislykket", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlEnqueued": "{count} i kø", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "startupError": "Noe gikk galt under oppstart av programmet. Feilen var: {error}\n\nOpprett en feilrapport på github.com/UnicornsOnLSD/finamp med en skjermavbildning av denne siden. Hvis problemet vedvarer kan du tømme programdata for å tilbakestille programmet.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "redownloadedItems": "{count,plural, =0{Ingenting trenger å lastes ned igjen.} =1{Lastet ned igjen {count} element} other{Lastet ned igjen {count} elementer}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "logs": "Loggføring", + "@logs": {}, + "password": "Passord", + "@password": {}, + "username": "Brukernavn", + "@username": {}, + "serverUrl": "Tjener-nettadresse", + "@serverUrl": {}, + "emptyServerUrl": "Tjener-nettadressen kan ikke stå tom", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "urlTrailingSlash": "Nettadressen kan ikke inneholde avsluttende skråstrek", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "internalExternalIpExplanation": "Hvis du vil ha tilgang til Jellyfin-tjeneren din annensteds fra må du bruke din eksterne IP-adresse.\n\nHvis tjeneren din er på en HTTP-port (80/443) må du angi en port. Dette er sannsynligvis tilfelle hvis tjeneren din er bak en omvendt mellomtjener.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "startMixNoSongsAlbum": "Lang-trykk et album for å legge til eller fjerne det fra miksbyggeren før du starter en miks", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "startMixNoSongsArtist": "Lang-trykk på en artist for å legge til eller fjerne vedkommende fra miksbyggeren før du starter en miks", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "shuffleAll": "Tilfelding avspilling av alt", + "@shuffleAll": {}, + "revenue": "Omsetning", + "@revenue": {}, + "downloadedMissingImages": "{count,plural, =0{Fant ingen manglende bilder} =1{Lastet ned {count} manglende bilde} other{Lastet ned {count} manglende bilder}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadCount": "{count,plural, =1{{count} nedlasting} other{{count} nedlastinger}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsCount": "{count,plural,=1{{count} element} other{{count} elementer}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "error": "Feil", + "@error": {}, + "discNumber": "Disk {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "playButtonLabel": "Spill", + "@playButtonLabel": {}, + "editPlaylistNameTooltip": "Rediger spillelistenavn", + "@editPlaylistNameTooltip": {}, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "noErrors": "Ingen feil.", + "@noErrors": {}, + "stackTrace": "Stabelspor", + "@stackTrace": {}, + "applicationLegalese": "Lisensiert MPL 2.0, med kildekode tilgjengelig på\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "transcoding": "Transkoding", + "@transcoding": {}, + "failedToGetSongFromDownloadId": "Klarte ikke å hente spor fra nedlastings-ID", + "@failedToGetSongFromDownloadId": {}, + "favourite": "Favorittmerk", + "@favourite": {}, + "addDownloads": "Legg til nedlastinger", + "@addDownloads": {}, + "downloadsAdded": "Nedlastinger lagt til.", + "@downloadsAdded": {}, + "layoutAndTheme": "Posisjonering og draktvalg", + "@layoutAndTheme": {}, + "shuffleButtonLabel": "Tilfeldig avspilling", + "@shuffleButtonLabel": {}, + "location": "Sted", + "@location": {}, + "downloadLocations": "Nedlastingssteder", + "@downloadLocations": {}, + "downloadedImagesCount": "{count,plural,=1{{count} bilde} other{{count} bilder}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "errorScreenError": "En feil inntraff under innhenting av listen over feil. Herfra bør du kanskje opprette en feilrapport på GitHub og slette programdataen.", + "@errorScreenError": {}, + "songCount": "{count,plural,=1{{count} spor} other{{count} spor}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "appDirectory": "Programmappe", + "@appDirectory": {}, + "bitrateSubtitle": "Høyere bitrate gir lyd i bedre kvalitet, på bekostning av høyere båndbreddebruk.", + "@bitrateSubtitle": {}, + "customLocation": "Egendefinert sted", + "@customLocation": {}, + "enableTranscodingSubtitle": "Transkoder musikkstrømmer på tjenersiden.", + "@enableTranscodingSubtitle": {}, + "addDownloadLocation": "Legg til nedlastingssted", + "@addDownloadLocation": {}, + "selectDirectory": "Velg mappe", + "@selectDirectory": {}, + "directoryMustBeEmpty": "Mappen må være tom", + "@directoryMustBeEmpty": {}, + "pathReturnSlashErrorMessage": "Stier som returnerer «/» kan ikke brukes.", + "@pathReturnSlashErrorMessage": {}, + "customLocationsBuggy": "Egendefinerte steder fungerer dårlig som følge av problemer med tilganger. Måter å løse det på vurderes, men akkurat nå anbefales ikke bruk.", + "@customLocationsBuggy": {}, + "shuffleAllSongCount": "Antall spor for tilfeldig avspilling", + "@shuffleAllSongCount": {}, + "shuffleAllSongCountSubtitle": "Mengden spor å laste inn ved bruk av «Tilfeldig avspilling av alle spor»-knappen.", + "@shuffleAllSongCountSubtitle": {}, + "viewType": "Visningstype", + "@viewType": {}, + "viewTypeSubtitle": "Visningstype for musikkskjermen", + "@viewTypeSubtitle": {}, + "enterLowPriorityStateOnPause": "Gå inn i lavprioritetstilstand ved pause", + "@enterLowPriorityStateOnPause": {}, + "enterLowPriorityStateOnPauseSubtitle": "Lar merknaden bli dratt unna under pause. Tillater også Android å drepe tjenesten når pauset.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "portrait": "Stående", + "@portrait": {}, + "landscape": "Liggende", + "@landscape": {}, + "gridCrossAxisCount": "{value} rutenettsakseantall", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "gridCrossAxisCountSubtitle": "Mengden rutenettsflis å bruke per rad i {value}-modus.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "theme": "Drakt", + "@theme": {}, + "showTextOnGridViewSubtitle": "Hvorvidt tekst (navn, artist, osv.( skal vises i rutenettsmusikkskjermen.", + "@showTextOnGridViewSubtitle": {}, + "showCoverAsPlayerBackgroundSubtitle": "Hvorvidt tilslørt omslag skal brukes som bakgrunn på avspillerskjermen.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "hideSongArtistsIfSameAsAlbumArtists": "Skjul artister for sporet hvis samme som albumsartister", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Hvorvidt sporartister skal vises på albumsskjermen hvis forskjellig fra albumsartister.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "showTextOnGridView": "Vis tekst i rutenettsvisning", + "@showTextOnGridView": {}, + "showCoverAsPlayerBackground": "Vis tilslørt omslag som avspillerbakgrunn", + "@showCoverAsPlayerBackground": {}, + "removedFromPlaylist": "Fjernet fra spilleliste.", + "@removedFromPlaylist": {}, + "removeFromPlaylistTooltip": "Fjern fra spilleliste", + "@removeFromPlaylistTooltip": {}, + "bufferDuration": "Hurtiglagervarighet", + "@bufferDuration": {}, + "showUncensoredLogMessage": "Denne loggen inneholder din innloggingsinfo. Vis?", + "@showUncensoredLogMessage": {}, + "removeFromPlaylistTitle": "Fjern fra spilleliste", + "@removeFromPlaylistTitle": {}, + "bufferDurationSubtitle": "Antall sekunder avspilleren skal mellomlagre. Krever programomstart.", + "@bufferDurationSubtitle": {}, + "insertedIntoQueue": "Lagt i kø.", + "@insertedIntoQueue": { + "description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end." + }, + "refresh": "Gjenoppfrisk", + "@refresh": {}, + "confirm": "Bekreft", + "@confirm": {}, + "language": "Språk", + "@language": {}, + "playNext": "Spill neste", + "@playNext": { + "description": "Popup menu item title for inserting an item into the play queue after the currently-playing item." + }, + "resetTabs": "Tilbakestill faner", + "@resetTabs": {}, + "noMusicLibrariesBody": "Finamp fant ingen musikkbibliotek. Forsikre deg om at din Jellyfin-tjener inneholder minst ett bibliotek med innholdstype satt til «Musikk».", + "@noMusicLibrariesBody": {}, + "disableGesture": "Skru av håndvendinger", + "@disableGesture": {}, + "noMusicLibrariesTitle": "Ingen musikkbibliotek", + "@noMusicLibrariesTitle": { + "description": "Title for message that shows on the views screen when no music libraries could be found." + }, + "disableGestureSubtitle": "Hvorvidt håndvendinger skal skrus av.", + "@disableGestureSubtitle": {} +} diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb new file mode 100644 index 0000000..21552ed --- /dev/null +++ b/lib/l10n/app_nl.arb @@ -0,0 +1,491 @@ +{ + "responseError401": "{error} Status code {statusCode}. Dit betekenr waarschijnlijk dat u de verkeerde gebruikersnaam/wachtwoord hebt gebruikt, of uw apparaat niet langer geauthenticeerd is.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "addDownloads": "Voeg downloads toe", + "@addDownloads": {}, + "addButtonLabel": "Toevoegen", + "@addButtonLabel": {}, + "artists": "Artiesten", + "@artists": {}, + "albumArtist": "Album Artiest", + "@albumArtist": {}, + "artist": "Artiest", + "@artist": {}, + "budget": "Budget", + "@budget": {}, + "bitrate": "Bitrate", + "@bitrate": {}, + "anErrorHasOccured": "Er is een fout opgetreden.", + "@anErrorHasOccured": {}, + "addToPlaylistTitle": "Toevoegen aan afspeellijst", + "@addToPlaylistTitle": {}, + "addedToQueue": "Toegevoegd aan rij.", + "@addedToQueue": {}, + "addToQueue": "Voeg toe aan rij", + "@addToQueue": {}, + "album": "Album", + "@album": {}, + "albums": "Albums", + "@albums": {}, + "couldNotFindLibraries": "Kon geen bibliotheken vinden.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "responseError": "{error} Status code {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "addDownloadLocation": "Voeg een downloadlocatie toe", + "@addDownloadLocation": {}, + "shuffleAllSongCountSubtitle": "Hoeveelheid liedjes geladen moeten worden bij gebruik van de shuffle knop.", + "@shuffleAllSongCountSubtitle": {}, + "dlComplete": "{count} volledig", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "clear": "Opschonen", + "@clear": {}, + "addFavourite": "Toevoegen aan favorieten", + "@addFavourite": {}, + "addToPlaylistTooltip": "Toevoegen aan afspeellijst", + "@addToPlaylistTooltip": {}, + "gridCrossAxisCountSubtitle": "Hoeveelheid rijen te gebruiken in {value}-modus.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "errorScreenError": "Er is een fout opgetreden bij het opvragen van de lijst. Probeer een issue te maken op GitHub en verwijder app data", + "@errorScreenError": {}, + "areYouSure": "Bent u zeker?", + "@areYouSure": {}, + "audioService": "Audiodienst", + "@audioService": {}, + "bitrateSubtitle": "Het gebruik van een hogere bitrate geeft betere audiokwaliteit, maar gebruikt meer netwerkbandbreedte.", + "@bitrateSubtitle": {}, + "cancelSleepTimer": "Annuleer sleep timer?", + "@cancelSleepTimer": {}, + "communityRating": "Gemeenschapsbeoordeling", + "@communityRating": {}, + "downloadCount": "{count,plural, =1{{count} download} other{{count} downloads}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedImagesCount": "{count,plural,=1{{count} plaatje} other{{count} plaatjes}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsCount": "{count,plural,=1{{count} item} other{{count} items}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlFailed": "{count} mislukt", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "appDirectory": "Applicatie folder", + "@appDirectory": {}, + "customLocation": "Persoonlijke locatie", + "@customLocation": {}, + "dark": "Donker", + "@dark": {}, + "createButtonLabel": "CREËER", + "@createButtonLabel": {}, + "direct": "DIRECT", + "@direct": {}, + "songCount": "{count,plural,=1{{count} Nummer} other{{count} Nummers}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "serverUrl": "Server URL", + "@serverUrl": {}, + "internalExternalIpExplanation": "Om toegang tot de Jellyfin server te krijgen op afstand, dien je een extern IP te gebruiken.\n\nWanneer de server een HTTP-poort (80 of 443) gebruikt, hoef je deze niet in te vullen. Dit is waarschijnlijk het geval wanneer de server zich achter een reverse proxy bevindt.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "username": "Gebruikersnaam", + "@username": {}, + "password": "Wachtwoord", + "@password": {}, + "logs": "Logbestanden", + "@logs": {}, + "next": "Volgende", + "@next": {}, + "urlTrailingSlash": "De URL mag geen / bevatten aan het einde", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "selectMusicLibraries": "Selecteer Muziekbibliotheken", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "unknownName": "Onbekende Naam", + "@unknownName": {}, + "songs": "Liedjes", + "@songs": {}, + "playlists": "Afspeellijsten", + "@playlists": {}, + "startMix": "Mix starten", + "@startMix": {}, + "genres": "Genres", + "@genres": {}, + "startMixNoSongsArtist": "Druk lang op een artiest om deze toe te voegen of te verwijderen van de mix-bouwer alvorens de mix te starten", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "startMixNoSongsAlbum": "Druk lang op een album om deze toe te voegen of te verwijderen van de mix-bouwer alvorens de mix te starten", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "music": "Muziek", + "@music": {}, + "favourites": "Favorieten", + "@favourites": {}, + "shuffleAll": "Alles shufflen", + "@shuffleAll": {}, + "settings": "Instellingen", + "@settings": {}, + "offlineMode": "Offline Modus", + "@offlineMode": {}, + "sortOrder": "Sorteervolgorde", + "@sortOrder": {}, + "finamp": "Finamp", + "@finamp": {}, + "downloads": "Downloads", + "@downloads": {}, + "sortBy": "Sorteer met", + "@sortBy": {}, + "criticRating": "Criticusbeoordeling", + "@criticRating": {}, + "dateAdded": "Datum toegevoegd", + "@dateAdded": {}, + "datePlayed": "Datum afgespeeld", + "@datePlayed": {}, + "playCount": "Aantal keren afgespeeld", + "@playCount": {}, + "premiereDate": "Premieredatum", + "@premiereDate": {}, + "productionYear": "Productiejaar", + "@productionYear": {}, + "name": "Naam", + "@name": {}, + "random": "Willekeurig", + "@random": {}, + "revenue": "Inkomsten", + "@revenue": {}, + "runtime": "Duur", + "@runtime": {}, + "downloadMissingImages": "Download ontbrekende plaatjes", + "@downloadMissingImages": {}, + "downloadedMissingImages": "{count,plural, =0{Geen ontbrekende plaatjes gevonden} =1{{count} ontbrekend plaatje gedownloaded} other{{count} ontbrekende plaatjes gedownloaded}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "downloadErrors": "Download fouten", + "@downloadErrors": {}, + "dlEnqueued": "{count} gepland", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "{count} bezig", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "Download Fouten", + "@downloadErrorsTitle": {}, + "noErrors": "Geen fouten!", + "@noErrors": {}, + "failedToGetSongFromDownloadId": "Het nummer kon niet gevonden worden met deze download ID", + "@failedToGetSongFromDownloadId": {}, + "error": "Fout", + "@error": {}, + "playButtonLabel": "AFSPELEN", + "@playButtonLabel": {}, + "editPlaylistNameTooltip": "Pas de naam van de playlist aan", + "@editPlaylistNameTooltip": {}, + "editPlaylistNameTitle": "Pas de naam van de Playlist aan", + "@editPlaylistNameTitle": {}, + "shuffleButtonLabel": "SHUFFLE", + "@shuffleButtonLabel": {}, + "updateButtonLabel": "UPDATE", + "@updateButtonLabel": {}, + "playlistNameUpdated": "De naam van de playlist is aangepast.", + "@playlistNameUpdated": {}, + "favourite": "Favoriet", + "@favourite": {}, + "downloadsDeleted": "Downloads verwijderd.", + "@downloadsDeleted": {}, + "location": "Plaats", + "@location": {}, + "downloadsAdded": "Downloads toegevoegd.", + "@downloadsAdded": {}, + "shareLogs": "Deel de log", + "@shareLogs": {}, + "stackTrace": "Strack-trace", + "@stackTrace": {}, + "applicationLegalese": "Uitgegeven onder de Mozilla Public License 2.0. Broncode beschikbaar op:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "downloadLocations": "Downloadlocaties", + "@downloadLocations": {}, + "transcoding": "Converteren", + "@transcoding": {}, + "layoutAndTheme": "Layout & Thema", + "@layoutAndTheme": {}, + "notAvailableInOfflineMode": "Niet beschikbaar in offline-modus", + "@notAvailableInOfflineMode": {}, + "logOut": "Uitloggen", + "@logOut": {}, + "downloadedSongsWillNotBeDeleted": "Gedownloade nummers worden niet verwijderd", + "@downloadedSongsWillNotBeDeleted": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin gebruikt AAC voor conversie", + "@jellyfinUsesAACForTranscoding": {}, + "selectDirectory": "Selecteer map", + "@selectDirectory": {}, + "unknownError": "Onbekende Fout", + "@unknownError": {}, + "directoryMustBeEmpty": "Folder moet leeg zijn", + "@directoryMustBeEmpty": {}, + "enterLowPriorityStateOnPause": "Ga naar een lage prioriteit wanneer gepauseerd", + "@enterLowPriorityStateOnPause": {}, + "viewType": "Type bekijken", + "@viewType": {}, + "viewTypeSubtitle": "Type voor het muziekscherm bekijken", + "@viewTypeSubtitle": {}, + "shuffleAllSongCount": "Aantal liedjes in de shuffle", + "@shuffleAllSongCount": {}, + "list": "Lijst", + "@list": {}, + "grid": "Raster", + "@grid": {}, + "portrait": "Portret", + "@portrait": {}, + "landscape": "Landschap", + "@landscape": {}, + "gridCrossAxisCount": "{value} Aantal assen", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "showTextOnGridView": "Tekst in rooster tonen", + "@showTextOnGridView": {}, + "showTextOnGridViewSubtitle": "Tekst (titel, artiest, etc.) laten zien op het muziekscherm.", + "@showTextOnGridViewSubtitle": {}, + "theme": "Thema", + "@theme": {}, + "disableGestureSubtitle": "Gebaren in- of uitschakelen.", + "@disableGestureSubtitle": {}, + "light": "Licht", + "@light": {}, + "tabs": "Tabs", + "@tabs": {}, + "yesButtonLabel": "JA", + "@yesButtonLabel": {}, + "noButtonLabel": "NEE", + "@noButtonLabel": {}, + "setSleepTimer": "Slaaptimer instellen", + "@setSleepTimer": {}, + "minutes": "Minuten", + "@minutes": {}, + "invalidNumber": "Ongeldig getal", + "@invalidNumber": {}, + "sleepTimerTooltip": "Slaaptimer", + "@sleepTimerTooltip": {}, + "newPlaylist": "Nieuwe afspeellijst", + "@newPlaylist": {}, + "playlistCreated": "Afspeellijst gecreëerd.", + "@playlistCreated": {}, + "noAlbum": "Geen album", + "@noAlbum": {}, + "noItem": "Geen element", + "@noItem": {}, + "noArtist": "Geen artiest", + "@noArtist": {}, + "unknownArtist": "Onbekende artiest", + "@unknownArtist": {}, + "streaming": "STREAMING", + "@streaming": {}, + "downloaded": "GEDOWNLOAD", + "@downloaded": {}, + "statusError": "STATUS FOUT", + "@statusError": {}, + "queue": "Afspeelrij", + "@queue": {}, + "replaceQueue": "Afspeelrij vervangen", + "@replaceQueue": {}, + "instantMix": "Instant mix", + "@instantMix": {}, + "goToAlbum": "Ga naar album", + "@goToAlbum": {}, + "removeFavourite": "Verwijder favoriet", + "@removeFavourite": {}, + "queueReplaced": "Rij vervangen.", + "@queueReplaced": {}, + "startingInstantMix": "Instantmix starten.", + "@startingInstantMix": {}, + "removeFromMix": "Uit mix verwijderen", + "@removeFromMix": {}, + "addToMix": "Aan mix toevoegen", + "@addToMix": {}, + "bufferDuration": "Bufferlengte", + "@bufferDuration": {}, + "bufferDurationSubtitle": "De lengte van de buffer, in seconden. Dit vereist een herstart.", + "@bufferDurationSubtitle": {}, + "redownloadedItems": "{count,plural, =0{Geen downloads nodig.} =1{{count} element opnieuw gedownload} other{ {count} elementen opnieuw gedownload}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "enterLowPriorityStateOnPauseSubtitle": "De notificatie kan weggeswiped worden wanneer gepauseerd. Hierdoor kan Android de service stoppen.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "logsCopied": "De log is gekopieerd.", + "@logsCopied": {}, + "discNumber": "Disk {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "required": "Verplicht", + "@required": {}, + "message": "Bericht", + "@message": {}, + "enableTranscodingSubtitle": "Converteert muziekstreams op de server.", + "@enableTranscodingSubtitle": {}, + "enableTranscoding": "Conversie inschakelen", + "@enableTranscoding": {}, + "pathReturnSlashErrorMessage": "Paden met \"/\" kunnen niet worden gebruikt", + "@pathReturnSlashErrorMessage": {}, + "customLocationsBuggy": "Persoonlijke locaties hebben veel bug door permissies. We denken over oplossingen hiervoor. Voor nu raden we aan deze niet te gebruiken.", + "@customLocationsBuggy": {}, + "hideSongArtistsIfSameAsAlbumArtists": "Liedartiest verbergen wanneer deze hetzelfde is als de albumartiest", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "showCoverAsPlayerBackground": "Geblurde cover op spelerachtergrond tonen", + "@showCoverAsPlayerBackground": {}, + "showCoverAsPlayerBackgroundSubtitle": "Geblurde coverafbeelding gebruiken als achtergrond van het afspeelscherm.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "disableGesture": "Gebaren uitschakelen", + "@disableGesture": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Liedjesartiest tonen wanneer deze hetzelfde is als de albumartiest.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "system": "Systeem", + "@system": {}, + "transcode": "CONVERTEREN", + "@transcode": {}, + "startupError": "Er ging iets mis bij het starten van de app. Dit was de foutmelding: {error}\n\nCreëer een issue op github.com/UnicornsOnLSD/finamp met een schermafbeelding van deze pagina. Wanneer het probleem zich vaker voordoet, kan je de app-data verwijderen om de app te resetten.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "emptyServerUrl": "De URL van je server mag niet leeg zijn", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "urlStartWithHttps": "De URL moet beginnen met http:// of https://", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "removeFromPlaylistTooltip": "Verwijder van afspeellijst", + "@removeFromPlaylistTooltip": {}, + "removeFromPlaylistTitle": "Verwijder van afspeellijst", + "@removeFromPlaylistTitle": {}, + "removedFromPlaylist": "Verwijderd van afspeellijst.", + "@removedFromPlaylist": {}, + "resetTabs": "Tabbladen resetten", + "@resetTabs": {}, + "language": "Taal", + "@language": {}, + "confirm": "Bevestigen", + "@confirm": {}, + "showUncensoredLogMessage": "Dit logboek bevat uw inloggegevens. Tonen?", + "@showUncensoredLogMessage": {} +} diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb new file mode 100644 index 0000000..56f9976 --- /dev/null +++ b/lib/l10n/app_pl.arb @@ -0,0 +1,539 @@ +{ + "premiereDate": "Data premiery", + "@premiereDate": {}, + "password": "Hasło", + "@password": {}, + "logs": "Logi", + "@logs": {}, + "next": "Dalej", + "@next": {}, + "unknownName": "Bez nazwy", + "@unknownName": {}, + "playlists": "Listy odtwarzania", + "@playlists": {}, + "startMix": "Rozpocznij mix", + "@startMix": {}, + "clear": "Wyczyść", + "@clear": {}, + "favourites": "Ulubione", + "@favourites": {}, + "sortOrder": "Porządek sortowania", + "@sortOrder": {}, + "downloads": "Pobrane", + "@downloads": {}, + "album": "Album", + "@album": {}, + "albumArtist": "Wykonawca albumu", + "@albumArtist": {}, + "sortBy": "Sortuj według", + "@sortBy": {}, + "artist": "Wykonawca", + "@artist": {}, + "communityRating": "Ocena społeczności", + "@communityRating": {}, + "playCount": "Liczba odtworzeń", + "@playCount": {}, + "dateAdded": "Data dodania", + "@dateAdded": {}, + "datePlayed": "Data odtwarzania", + "@datePlayed": {}, + "name": "Nazwa", + "@name": {}, + "random": "Losowo", + "@random": {}, + "dlEnqueued": "{count} oczekuje", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "playButtonLabel": "ODTWARZAJ", + "@playButtonLabel": {}, + "shuffleButtonLabel": "LOSOWO", + "@shuffleButtonLabel": {}, + "editPlaylistNameTitle": "Edytuj nazwę listy odtwarzania", + "@editPlaylistNameTitle": {}, + "playlistNameUpdated": "Zaktualizowano nazwę listy odtwarzania.", + "@playlistNameUpdated": {}, + "favourite": "Ulubiony", + "@favourite": {}, + "required": "Wymagane", + "@required": {}, + "addButtonLabel": "DODAJ", + "@addButtonLabel": {}, + "transcoding": "Transkodowanie", + "@transcoding": {}, + "message": "Wiadomość", + "@message": {}, + "audioService": "Usługa audio", + "@audioService": {}, + "grid": "Siatka", + "@grid": {}, + "portrait": "Widok pionowy", + "@portrait": {}, + "landscape": "Widok poziomy", + "@landscape": {}, + "yesButtonLabel": "TAK", + "@yesButtonLabel": {}, + "invalidNumber": "Nieprawidłowa liczba", + "@invalidNumber": {}, + "playlistCreated": "Utworzono listę odtwarzania.", + "@playlistCreated": {}, + "newPlaylist": "Nowa lista odtwarzania", + "@newPlaylist": {}, + "noArtist": "Brak artysty", + "@noArtist": {}, + "addToQueue": "Dodaj do kolejki", + "@addToQueue": {}, + "instantMix": "Szybki miks", + "@instantMix": {}, + "queue": "Kolejka", + "@queue": {}, + "direct": "BEZPOŚREDNIE", + "@direct": {}, + "startingInstantMix": "Rozpocznij szybki miks.", + "@startingInstantMix": {}, + "anErrorHasOccured": "Wystąpił błąd.", + "@anErrorHasOccured": {}, + "serverUrl": "Adres URL serwera", + "@serverUrl": {}, + "emptyServerUrl": "Adres URL serwera nie może być pusty", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "urlStartWithHttps": "URL musi zaczynać się od http:// lub https://", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "username": "Nazwa użytkownika", + "@username": {}, + "urlTrailingSlash": "URL nie może kończyć się znakiem \"/\"", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "selectMusicLibraries": "Wybierz biblioteki z muzyką", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "couldNotFindLibraries": "Nie znaleziono żadnych bibliotek.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "songs": "Utwory", + "@songs": {}, + "albums": "Albumy", + "@albums": {}, + "artists": "Wykonawcy", + "@artists": {}, + "genres": "Gatunki", + "@genres": {}, + "addedToQueue": "Dodano do kolejki.", + "@addedToQueue": {}, + "queueReplaced": "Kolejka zastąpiona.", + "@queueReplaced": {}, + "error": "Błąd", + "@error": {}, + "noErrors": "Brak błędów!", + "@noErrors": {}, + "addFavourite": "Dodaj do ulubionych", + "@addFavourite": {}, + "addToPlaylistTooltip": "Dodaj do playlisty", + "@addToPlaylistTooltip": {}, + "addToPlaylistTitle": "Dodaj do Playlisty", + "@addToPlaylistTitle": {}, + "updateButtonLabel": "AKTUALIZUJ", + "@updateButtonLabel": {}, + "logOut": "Wyloguj", + "@logOut": {}, + "settings": "Ustawienia", + "@settings": {}, + "unknownError": "Nieznany błąd", + "@unknownError": {}, + "finamp": "Finamp", + "@finamp": {}, + "music": "Muzyka", + "@music": {}, + "shareLogs": "Udostępnij logi", + "@shareLogs": {}, + "list": "Lista", + "@list": {}, + "showCoverAsPlayerBackground": "Pokazuj rozmyty obraz jako tło odtwarzacza", + "@showCoverAsPlayerBackground": {}, + "location": "Lokalizacja", + "@location": {}, + "logsCopied": "Logi skopiowane.", + "@logsCopied": {}, + "areYouSure": "Czy na pewno?", + "@areYouSure": {}, + "unknownArtist": "Nieznany artysta", + "@unknownArtist": {}, + "offlineMode": "Tryb offline", + "@offlineMode": {}, + "editPlaylistNameTooltip": "Edytuj nazwę listy odtwarzania", + "@editPlaylistNameTooltip": {}, + "discNumber": "Dysk {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "downloaded": "Z POBRANYCH", + "@downloaded": {}, + "addDownloadLocation": "Dodaj lokalizację pobierania", + "@addDownloadLocation": {}, + "downloadLocations": "Lokalizacje pobierania", + "@downloadLocations": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin używa AAC do transkodowania", + "@jellyfinUsesAACForTranscoding": {}, + "theme": "Motyw", + "@theme": {}, + "goToAlbum": "Idź do albumu", + "@goToAlbum": {}, + "productionYear": "Rok produkcji", + "@productionYear": {}, + "enableTranscoding": "Włącz transkodowanie", + "@enableTranscoding": {}, + "downloadedSongsWillNotBeDeleted": "Pobrane utwory nie zostaną usunięte", + "@downloadedSongsWillNotBeDeleted": {}, + "downloadErrors": "Błędy pobierania", + "@downloadErrors": {}, + "downloadErrorsTitle": "Błędy pobierania", + "@downloadErrorsTitle": {}, + "light": "Jasny", + "@light": {}, + "customLocation": "Własna lokalizacja", + "@customLocation": {}, + "showTextOnGridView": "Pokaż tekst w widoku siatki", + "@showTextOnGridView": {}, + "directoryMustBeEmpty": "Folder musi być pusty", + "@directoryMustBeEmpty": {}, + "downloadsDeleted": "Usunięto pobrane.", + "@downloadsDeleted": {}, + "downloadMissingImages": "Pobierz brakujące obrazy", + "@downloadMissingImages": {}, + "dlComplete": "{count} ukończono pomyślnie", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "shuffleAll": "Losuj wszystko", + "@shuffleAll": {}, + "criticRating": "Ocena krytyków", + "@criticRating": {}, + "budget": "Budżet", + "@budget": {}, + "revenue": "Dochód", + "@revenue": {}, + "downloadCount": "{count,plural, =1{{count} pobranie} other{{count} pobrań}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedImagesCount": "{count,plural,=1{{count} obraz} other{{count} obrazów}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlFailed": "{count} ukończono z błędami", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "{count} w trakcie", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadsAdded": "Dodano do pobierania.", + "@downloadsAdded": {}, + "addDownloads": "Wszystkie pobrania", + "@addDownloads": {}, + "layoutAndTheme": "Układ i motyw", + "@layoutAndTheme": {}, + "enableTranscodingSubtitle": "Transkoduje strumienie muzyczne po stronie serwera.", + "@enableTranscodingSubtitle": {}, + "bitrate": "Bitrate", + "@bitrate": {}, + "selectDirectory": "Wybierz folder", + "@selectDirectory": {}, + "pathReturnSlashErrorMessage": "Ścieżki nie mogą wskazywać \"/\"", + "@pathReturnSlashErrorMessage": {}, + "cancelSleepTimer": "Zatrzymać odliczanie?", + "@cancelSleepTimer": {}, + "system": "Systemowy", + "@system": {}, + "noButtonLabel": "NIE", + "@noButtonLabel": {}, + "createButtonLabel": "UTWÓRZ", + "@createButtonLabel": {}, + "noAlbum": "Brak albumu", + "@noAlbum": {}, + "noItem": "Brak elementu", + "@noItem": {}, + "streaming": "STRUMIENIOWANIE", + "@streaming": {}, + "replaceQueue": "Zastąp kolejkę", + "@replaceQueue": {}, + "statusError": "STATUS BŁĘDU", + "@statusError": {}, + "responseError": "{error} Kod statusu {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "notAvailableInOfflineMode": "Niedostępne w trybie offline", + "@notAvailableInOfflineMode": {}, + "dark": "Ciemny", + "@dark": {}, + "removeFavourite": "Usuń z ulubionych", + "@removeFavourite": {}, + "tabs": "Zakładki", + "@tabs": {}, + "setSleepTimer": "Ustaw wyłącznik czasowy", + "@setSleepTimer": {}, + "enterLowPriorityStateOnPause": "Przechodź w stan niskiego priorytetu w trakcie trwania pauzy", + "@enterLowPriorityStateOnPause": {}, + "songCount": "{count,plural,=1{{count} utwór} other{{count} utworów}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "startMixNoSongsArtist": "Kliknij i przytrzymaj nazwę wykonawcy, aby dodać go do mix-u przed jego rozpoczęciem", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "startMixNoSongsAlbum": "Kliknij i przytrzymaj nazwę albumu, aby dodać go do mix-u przed jego rozpoczęciem", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "appDirectory": "Folder aplikacji", + "@appDirectory": {}, + "downloadedItemsCount": "{count,plural,=1{{count} utwór} other{{count} utworów}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "bitrateSubtitle": "Wyższy bitrate dostarcza lepszą jakość, ale kosztem większego zużycia transferu danych.", + "@bitrateSubtitle": {}, + "hideSongArtistsIfSameAsAlbumArtists": "Ukryj wykonawców utworu jeśli odpowiadają wykonawcom albumu", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "customLocationsBuggy": "Niestandardowe lokalizacje są bardzo kłopotliwe ze względu na problemy z uprawnieniami. W chwili obecnej ich używanie nie jest zalecane.", + "@customLocationsBuggy": {}, + "gridCrossAxisCountSubtitle": "Ilość elementów w wierszu - {value}.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "shuffleAllSongCountSubtitle": "Ilość utworów do załadowania kiedy użyto przycisku \"Losowo\" na wszystkich utworach.", + "@shuffleAllSongCountSubtitle": {}, + "downloadedMissingImages": "{count,plural, =0{Nie brakuje żadnych obrazów} =1{Pobrano {count} brakujący obraz} other{Pobrano {count} brakujących obrazów}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "failedToGetSongFromDownloadId": "Nie udało się pobrać utworu z ID pobierania", + "@failedToGetSongFromDownloadId": {}, + "stackTrace": "Stos błędu", + "@stackTrace": {}, + "enterLowPriorityStateOnPauseSubtitle": "Pozwala usunąć powiadomienie podczas pauzy. Pozwala również systemowi Android na zabicie usługi podczas pauzy.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "viewTypeSubtitle": "Układ elementów", + "@viewTypeSubtitle": {}, + "shuffleAllSongCount": "Maksymalna ilość losowych utworów", + "@shuffleAllSongCount": {}, + "gridCrossAxisCount": "Elementy siatki ({value})", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "showCoverAsPlayerBackgroundSubtitle": "Określa czy używać rozmytego obrazu na ekranie odtwarzacza.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "sleepTimerTooltip": "Wyłącznik czasowy", + "@sleepTimerTooltip": {}, + "showTextOnGridViewSubtitle": "Określa czy wyświetlać tekst (tytuł, wykonawca itp.) na siatce elementów.", + "@showTextOnGridViewSubtitle": {}, + "viewType": "Widok", + "@viewType": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Określa czy wyświetlać wykonawców utworów na ekranie albumu, jeśli nie różnią się one od wykonawców albumów.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "responseError401": "{error} Kod {statusCode}. To najprawdopodobniej oznacza, że użyłeś/aś nieprawidłowej nazwy użytkownika lub hasła.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "transcode": "TRANSKODOWANE", + "@transcode": {}, + "applicationLegalese": "Licencja Mozilla Public License 2.0. Kod źródłowy dostępny na:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "errorScreenError": "Wystąpił błąd podczas pobierania listy błędów. Utwórz zgłoszenie na portalu GitHub i usuń dane aplikacji", + "@errorScreenError": {}, + "internalExternalIpExplanation": "Jeśli chcesz uzyskać dostęp do zdalnej instancji Jellyfin, musisz posiadać zewnętrzne IP.\n\nJeśli serwer jest dostępny na portach HTTP(S) (80/443), nie musisz określać portu.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "startupError": "Coś poszło nie tak podczas startu aplikacji. Wystąpił błąd :{error}\n\nProszę utworzyć zgłoszenie (https://github.com/jmshrv/finamp) wraz ze zrzutem ekranu. Jeśli problem będzie się powtarzać, usuń dane aplikacji.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "runtime": "Czas trwania", + "@runtime": {}, + "removeFromMix": "Usuń z miksu", + "@removeFromMix": {}, + "addToMix": "Dodaj do miksu", + "@addToMix": {}, + "disableGesture": "Wyłącz gesty", + "@disableGesture": {}, + "disableGestureSubtitle": "Czy wyłączyć gesty.", + "@disableGestureSubtitle": {}, + "minutes": "Minuty", + "@minutes": {}, + "bufferDuration": "Długość bufora", + "@bufferDuration": {}, + "bufferDurationSubtitle": "Ile odtwarzacz powinien buforować, w sekundach. Wymaga ponownego uruchomienia.", + "@bufferDurationSubtitle": {}, + "redownloadedItems": "{count,plural, =0{Nie potrzeba ponownego pobierania.} =1{Ponownie pobrano {count} element} other{Ponownie pobrano {count} elementów}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "removeFromPlaylistTooltip": "Usuń z listy odtwarzania", + "@removeFromPlaylistTooltip": {}, + "removeFromPlaylistTitle": "Usuń z Listy odtwarzania", + "@removeFromPlaylistTitle": {}, + "removedFromPlaylist": "Usunięto z listy odtwarzania.", + "@removedFromPlaylist": {}, + "language": "Język", + "@language": {}, + "confirm": "Potwierdź", + "@confirm": {}, + "showUncensoredLogMessage": "Ten log zawiera twoje dane logowania. Pokazać?", + "@showUncensoredLogMessage": {}, + "resetTabs": "Resetowanie zakładek", + "@resetTabs": {}, + "playNext": "Następne", + "@playNext": { + "description": "Popup menu item title for inserting an item into the play queue after the currently-playing item." + }, + "interactions": "Interakcje", + "@interactions": {}, + "insertedIntoQueue": "Dodano do kolejki.", + "@insertedIntoQueue": { + "description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end." + }, + "noMusicLibrariesTitle": "Brak bibliotek muzycznych", + "@noMusicLibrariesTitle": { + "description": "Title for message that shows on the views screen when no music libraries could be found." + }, + "deleteDownloadsConfirmButtonText": "Usuń", + "@deleteDownloadsConfirmButtonText": { + "description": "Shown in the confirmation dialog for deleting downloaded media from the local device." + }, + "deleteDownloadsAbortButtonText": "Cofnij", + "@deleteDownloadsAbortButtonText": {}, + "syncDownloadedPlaylists": "Synchronizuj pobrane playlisty", + "@syncDownloadedPlaylists": {}, + "showFastScroller": "Pokaż szybkie przewijanie", + "@showFastScroller": {}, + "refresh": "ODŚWIEŻ", + "@refresh": {}, + "swipeInsertQueueNext": "Odtwórz Przewiniętą Piosenkę Następnie", + "@swipeInsertQueueNext": {}, + "swipeInsertQueueNextSubtitle": "Włącz wstawianie utworu jako następnego elementu w kolejce po przesunięciu palcem na liście utworów zamiast dołączania go na końcu.", + "@swipeInsertQueueNextSubtitle": {}, + "deleteDownloadsPrompt": "Czy napewno chcesz usunąć {itemType, select, album{album} playlist{playlist} artist{artist} genre{genre} track{song} other{}} '{itemName}' z tego urządzenia?", + "@deleteDownloadsPrompt": { + "placeholders": { + "itemName": { + "type": "String", + "example": "Abandon Ship" + }, + "itemType": { + "type": "String", + "example": "album" + } + }, + "description": "Confirmation prompt shown before deleting downloaded media from the local device, destructive action, doesn't affect the media on the server." + }, + "noMusicLibrariesBody": "Finamp nie mógł znaleźć żadnych bibliotek muzycznych. Upewnij się, że serwer Jellyfin zawiera co najmniej jedną bibliotekę z typem zawartości ustawionym na \"Muzyka\".", + "@noMusicLibrariesBody": {}, + "redesignBeta": "Przeprojektowanie Beta", + "@redesignBeta": {} +} diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb new file mode 100644 index 0000000..1ab0a68 --- /dev/null +++ b/lib/l10n/app_pt.arb @@ -0,0 +1,517 @@ +{ + "noAlbum": "Nenhum Álbum", + "@noAlbum": {}, + "replaceQueue": "Trocar a Fila", + "@replaceQueue": {}, + "instantMix": "Mistura Instantânea", + "@instantMix": {}, + "addFavourite": "Adicionar Favorito", + "@addFavourite": {}, + "addedToQueue": "Adicionado à fila.", + "@addedToQueue": {}, + "internalExternalIpExplanation": "Se pretende aceder ao seu servidor Jellyfin remotamente, será necessário utilizar o seu IP público.\n\nNo caso do seu servidor utilizar uma das portas padrão do HTTP (80/443), não vai precisar de indicar uma porta. Este será o caso mais provável caso utilize um proxy inverso.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "urlStartWithHttps": "O URL deverá começar com http:// ou https://", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "password": "Palavra-passe", + "@password": {}, + "logs": "Registos", + "@logs": {}, + "next": "Próximo", + "@next": {}, + "noErrors": "Sem erros!", + "@noErrors": {}, + "errorScreenError": "Ocorreu um erro ao aceder à lista de erros! Neste caso, recomendados que abra um issue no nosso GitHub e que limpe os dados da aplicação", + "@errorScreenError": {}, + "shuffleButtonLabel": "MISTURAR", + "@shuffleButtonLabel": {}, + "location": "Localização", + "@location": {}, + "downloadsAdded": "Descargas adicionadas.", + "@downloadsAdded": {}, + "downloadedSongsWillNotBeDeleted": "Músicas descarregadas não serão apagadas", + "@downloadedSongsWillNotBeDeleted": {}, + "areYouSure": "Tem certeza?", + "@areYouSure": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin usa AAC para transcodificação", + "@jellyfinUsesAACForTranscoding": {}, + "bitrateSubtitle": "Uma taxa de bits mais alta resulta em áudio de maior qualidade, ao custo de maior largura de banda.", + "@bitrateSubtitle": {}, + "enterLowPriorityStateOnPause": "Entrar Estado de Baixa-Prioridade durante Pausa", + "@enterLowPriorityStateOnPause": {}, + "viewTypeSubtitle": "Tipo de visualização para o ecrã de músicas", + "@viewTypeSubtitle": {}, + "list": "Lista", + "@list": {}, + "gridCrossAxisCount": "{value} Contagem Eixo Cruzado da Grade", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "goToAlbum": "Ir para Álbum", + "@goToAlbum": {}, + "removeFavourite": "Remover Favorito", + "@removeFavourite": {}, + "queueReplaced": "Fila trocada.", + "@queueReplaced": {}, + "startingInstantMix": "Iniciando mistura instantânea.", + "@startingInstantMix": {}, + "anErrorHasOccured": "Ocorreu um erro.", + "@anErrorHasOccured": {}, + "removeFromMix": "Remover da Mistura", + "@removeFromMix": {}, + "addToMix": "Adicionar à Mistura", + "@addToMix": {}, + "serverUrl": "URL do servidor", + "@serverUrl": {}, + "emptyServerUrl": "O URL do servidor não pode estar em branco", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "urlTrailingSlash": "O URL não pode ter uma barra no fim", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "username": "Utilizador", + "@username": {}, + "couldNotFindLibraries": "Não foi possível encontrar nenhuma biblioteca.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "unknownName": "Nome desconhecido", + "@unknownName": {}, + "selectMusicLibraries": "Seleccione as suas bibliotecas de música", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "songs": "Músicas", + "@songs": {}, + "albums": "Álbuns", + "@albums": {}, + "artists": "Artistas", + "@artists": {}, + "genres": "Géneros", + "@genres": {}, + "playlists": "Listas de Reprodução", + "@playlists": {}, + "startMix": "Iniciar Mistura", + "@startMix": {}, + "music": "Música", + "@music": {}, + "clear": "Limpar", + "@clear": {}, + "favourites": "Favoritos", + "@favourites": {}, + "shuffleAll": "Misturar todas", + "@shuffleAll": {}, + "finamp": "Finamp", + "@finamp": {}, + "downloads": "Transferências", + "@downloads": {}, + "settings": "Definições", + "@settings": {}, + "offlineMode": "Modo Offline", + "@offlineMode": {}, + "sortOrder": "Ordenação", + "@sortOrder": {}, + "sortBy": "Ordenar por", + "@sortBy": {}, + "album": "Álbum", + "@album": {}, + "albumArtist": "Artista do Álbum", + "@albumArtist": {}, + "artist": "Artista", + "@artist": {}, + "budget": "Orçamento", + "@budget": {}, + "communityRating": "Avaliação da Comunidade", + "@communityRating": {}, + "criticRating": "Avaliação dos Críticos", + "@criticRating": {}, + "dateAdded": "Adicionado em", + "@dateAdded": {}, + "datePlayed": "Reproduzido em", + "@datePlayed": {}, + "playCount": "Contagem de reproduções", + "@playCount": {}, + "premiereDate": "Data de Lançamento", + "@premiereDate": {}, + "productionYear": "Ano de Produção", + "@productionYear": {}, + "name": "Nome", + "@name": {}, + "random": "Aleatório", + "@random": {}, + "revenue": "Receita", + "@revenue": {}, + "runtime": "Duração", + "@runtime": {}, + "downloadMissingImages": "Transferir imagens inexistentes", + "@downloadMissingImages": {}, + "downloadErrors": "Erros de transferência", + "@downloadErrors": {}, + "downloadedMissingImages": "{count,plural, =0{Não foram encontradas imagens em falta} =1{{count} imagem em falta transferida} other{{count} imagens em falta descarregadas}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadCount": "{count,plural, =1{{count} descarregado} other{{count} descarregados}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "dlComplete": "{count} concluídas", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlFailed": "{count} falharam", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlEnqueued": "{count} em espera", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "{count} em execução", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "Erros de Transferência", + "@downloadErrorsTitle": {}, + "failedToGetSongFromDownloadId": "Falha ao adquirir música do ID da descarga", + "@failedToGetSongFromDownloadId": {}, + "error": "Erro", + "@error": {}, + "discNumber": "Disco {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "playButtonLabel": "REPRODUZIR", + "@playButtonLabel": {}, + "editPlaylistNameTooltip": "Editar nome da lista de reprodução", + "@editPlaylistNameTooltip": {}, + "editPlaylistNameTitle": "Editar Nome da Lista de Reprodução", + "@editPlaylistNameTitle": {}, + "required": "Obrigatório", + "@required": {}, + "updateButtonLabel": "ACTUALIZAR", + "@updateButtonLabel": {}, + "playlistNameUpdated": "Nome da lista de reprodução atualizado.", + "@playlistNameUpdated": {}, + "favourite": "Favorito", + "@favourite": {}, + "downloadsDeleted": "Descargas apagadas.", + "@downloadsDeleted": {}, + "addDownloads": "Adicionar Descargas", + "@addDownloads": {}, + "message": "Mensagem", + "@message": {}, + "stackTrace": "Traçado da Pilha", + "@stackTrace": {}, + "addButtonLabel": "ADICIONAR", + "@addButtonLabel": {}, + "shareLogs": "Compartilhar relatórios", + "@shareLogs": {}, + "logsCopied": "Relatórios copiados.", + "@logsCopied": {}, + "applicationLegalese": "Licenciado com a Licença Pública Mozilla 2.0. Código-fonte disponível em:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "transcoding": "Transcodificando", + "@transcoding": {}, + "downloadLocations": "Locais de Descargas", + "@downloadLocations": {}, + "audioService": "Serviço de Áudio", + "@audioService": {}, + "layoutAndTheme": "Composição & Tema", + "@layoutAndTheme": {}, + "notAvailableInOfflineMode": "Não disponível no modo desconectado", + "@notAvailableInOfflineMode": {}, + "logOut": "Sair", + "@logOut": {}, + "enableTranscoding": "Ativar Transcodificação", + "@enableTranscoding": {}, + "bitrate": "Taxa de bits", + "@bitrate": {}, + "customLocation": "Localização Customizada", + "@customLocation": {}, + "appDirectory": "Diretório de apps", + "@appDirectory": {}, + "addDownloadLocation": "Adicionar Localização de Descargas", + "@addDownloadLocation": {}, + "selectDirectory": "Selecione Diretório", + "@selectDirectory": {}, + "unknownError": "Erro Desconhecido", + "@unknownError": {}, + "pathReturnSlashErrorMessage": "Caminhos que retornam \"/\" não podem ser usados", + "@pathReturnSlashErrorMessage": {}, + "directoryMustBeEmpty": "Diretório deve estar vazio", + "@directoryMustBeEmpty": {}, + "customLocationsBuggy": "Localizações customizadas são extremamente defeituosas devidas à problems com permissões. Estou pensando em maneiras de consertar isso, mas por enquanto não recomendaria usá-las.", + "@customLocationsBuggy": {}, + "shuffleAllSongCount": "Contagem de Misturar Todas as Músicas", + "@shuffleAllSongCount": {}, + "shuffleAllSongCountSubtitle": "Quantidade de músicas para carregar quando usando o botão misturar todas as músicas.", + "@shuffleAllSongCountSubtitle": {}, + "viewType": "Tipo de Visualização", + "@viewType": {}, + "grid": "Grade", + "@grid": {}, + "portrait": "Retrato", + "@portrait": {}, + "landscape": "Paisagem", + "@landscape": {}, + "gridCrossAxisCountSubtitle": "Quantidade de ícones da grade para usar em cada fila quando {value}.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "showTextOnGridViewSubtitle": "Mostrar ou não o texto (título, artista, etc) no ecrã de música grade.", + "@showTextOnGridViewSubtitle": {}, + "showCoverAsPlayerBackgroundSubtitle": "Usar ou não usar arte de capas desfocadas como fundo do ecrã to reprodutor.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "theme": "Tema", + "@theme": {}, + "system": "Sistema", + "@system": {}, + "light": "Claro", + "@light": {}, + "dark": "Escuro", + "@dark": {}, + "tabs": "Abas", + "@tabs": {}, + "cancelSleepTimer": "Cancelar o Cronômetro de Sono?", + "@cancelSleepTimer": {}, + "yesButtonLabel": "SIM", + "@yesButtonLabel": {}, + "noButtonLabel": "NÃO", + "@noButtonLabel": {}, + "setSleepTimer": "Configurar o Cronômetro de Sono", + "@setSleepTimer": {}, + "invalidNumber": "Número Inválido", + "@invalidNumber": {}, + "sleepTimerTooltip": "Cronômetro de Sono", + "@sleepTimerTooltip": {}, + "addToPlaylistTooltip": "Adicionar à lista de reprodução", + "@addToPlaylistTooltip": {}, + "addToPlaylistTitle": "Adicionar à Lista de Reprodução", + "@addToPlaylistTitle": {}, + "newPlaylist": "Nova Lista de Reprodução", + "@newPlaylist": {}, + "createButtonLabel": "CRIAR", + "@createButtonLabel": {}, + "playlistCreated": "Lista de reprodução criada.", + "@playlistCreated": {}, + "unknownArtist": "Artista Desconhecido", + "@unknownArtist": {}, + "noItem": "Nenhum Ítem", + "@noItem": {}, + "noArtist": "Nenhum Artista", + "@noArtist": {}, + "streaming": "TRANSMITINDO", + "@streaming": {}, + "downloaded": "DESCARREGADO", + "@downloaded": {}, + "transcode": "TRANSCODIFICAR", + "@transcode": {}, + "direct": "DIRETO", + "@direct": {}, + "statusError": "ERRO DE CONDIÇÃO", + "@statusError": {}, + "queue": "Fila", + "@queue": {}, + "addToQueue": "Adicionar à Fila", + "@addToQueue": {}, + "responseError": "{error} Código de condição {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "responseError401": "{error} Código de condição {statusCode}. Isto provavelmente significa que usou um nome de utilizador/palavra-passe errados, ou o seu cliente não está mais autenticado.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "removedFromPlaylist": "Removido da lista de reprodução.", + "@removedFromPlaylist": {}, + "hideSongArtistsIfSameAsAlbumArtists": "Ocultar artistas da música se forem iguais aos artistas do álbum", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "startMixNoSongsAlbum": "Antes de iniciar uma mistura, faça um toque longo num álbum para adicioná-lo ao editor de misturas", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "removeFromPlaylistTooltip": "Remover da lista de reprodução", + "@removeFromPlaylistTooltip": {}, + "bufferDuration": "Duração do buffer", + "@bufferDuration": {}, + "showUncensoredLogMessage": "Este log contém suas informações de login. Mostrar?", + "@showUncensoredLogMessage": {}, + "enableTranscodingSubtitle": "Transcodifica fluxos de música no lado do servidor.", + "@enableTranscodingSubtitle": {}, + "removeFromPlaylistTitle": "Remover da Lista de Reprodução", + "@removeFromPlaylistTitle": {}, + "songCount": "{count,plural,=1{{count} Música} other{{count} Músicas}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Se deve mostrar os artistas das músicas na tela do álbum, se não for diferente dos artistas do álbum.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "bufferDurationSubtitle": "Quanto o player deve armazenar em buffer, em segundos. Requer uma reinicialização.", + "@bufferDurationSubtitle": {}, + "insertedIntoQueue": "Inserido na fila.", + "@insertedIntoQueue": { + "description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end." + }, + "showCoverAsPlayerBackground": "Mostrar capa desfocada como plano de fundo do player", + "@showCoverAsPlayerBackground": {}, + "startMixNoSongsArtist": "Antes de iniciar uma mistura, faça um toque longo num artista para adicionar ou remover o mesmo do editor de misturas", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "downloadedImagesCount": "{count,plural,=1{{count} imagem} other{{count} imagens}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "minutes": "Minutos", + "@minutes": {}, + "showTextOnGridView": "Mostrar texto na visualização em grade", + "@showTextOnGridView": {}, + "confirm": "Confirme", + "@confirm": {}, + "language": "Linguagem", + "@language": {}, + "playNext": "Jogue a seguir", + "@playNext": { + "description": "Popup menu item title for inserting an item into the play queue after the currently-playing item." + }, + "enterLowPriorityStateOnPauseSubtitle": "Permite que a notificação seja apagada quando pausada. Também permite que o Android elimine o serviço quando pausado.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "redownloadedItems": "{count,plural, =0{Não são necessários novos downloads.} =1{{count} item baixado novamente} other{{count} itens baixados novamente}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "resetTabs": "Redefinir guias", + "@resetTabs": {}, + "disableGesture": "Desativar gestos", + "@disableGesture": {}, + "downloadedItemsCount": "{count,plural,=1{{count} item} other{{count} itens}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "disableGestureSubtitle": "Se deseja desativar gestos.", + "@disableGestureSubtitle": {}, + "startupError": "Ocorreu um erro durante o arranque da aplicação. O erro foi:{error}\n\nPor favor, abra um \"Issue\" em github.com/UnicornsOnLSD/finamp que contenha uma captura deste ecrã. Caso o problema persista, tente limpar os dados da aplicação para a repor para o estado original.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "refresh": "ACTUALIZAR", + "@refresh": {}, + "noMusicLibrariesBody": "A Finamp não encontrou nenhuma biblioteca musical. Certifique-se de que seu servidor Jellyfin contenha pelo menos uma biblioteca com o tipo de conteúdo definido como “Música”.", + "@noMusicLibrariesBody": {}, + "noMusicLibrariesTitle": "Sem bibliotecas de música", + "@noMusicLibrariesTitle": { + "description": "Title for message that shows on the views screen when no music libraries could be found." + }, + "deleteDownloadsConfirmButtonText": "Apagar", + "@deleteDownloadsConfirmButtonText": { + "description": "Shown in the confirmation dialog for deleting downloaded media from the local device." + }, + "deleteDownloadsAbortButtonText": "Cancelar", + "@deleteDownloadsAbortButtonText": {}, + "showFastScroller": "Mostrar rolagem rápida", + "@showFastScroller": {}, + "syncDownloadedPlaylists": "Sincronizar listas de reprodução descarregadas", + "@syncDownloadedPlaylists": {} +} diff --git a/lib/l10n/app_pt_BR.arb b/lib/l10n/app_pt_BR.arb new file mode 100644 index 0000000..772880d --- /dev/null +++ b/lib/l10n/app_pt_BR.arb @@ -0,0 +1,590 @@ +{ + "serverUrl": "URL do servidor", + "@serverUrl": {}, + "emptyServerUrl": "URL de servidor não pode ser vazia", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "urlStartWithHttps": "URL deve começar com http:// ou https://", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "urlTrailingSlash": "URL não pode incluir a barra final", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "username": "Usuário", + "@username": {}, + "password": "Senha", + "@password": {}, + "logs": "Registros", + "@logs": {}, + "next": "Próximo", + "@next": {}, + "playlists": "Listas de Reprodução", + "@playlists": {}, + "startMix": "Começar Miscelânea", + "@startMix": {}, + "music": "Música", + "@music": {}, + "clear": "Limpar", + "@clear": {}, + "favourites": "Favoritos", + "@favourites": {}, + "shuffleAll": "Embaralhar todas", + "@shuffleAll": {}, + "finamp": "Finamp", + "@finamp": {}, + "downloads": "Downloads", + "@downloads": {}, + "settings": "Configurações", + "@settings": {}, + "offlineMode": "Modo Offline", + "@offlineMode": {}, + "sortOrder": "Ordenação", + "@sortOrder": {}, + "sortBy": "Ordenar por", + "@sortBy": {}, + "album": "Álbum", + "@album": {}, + "dateAdded": "Adicionado em", + "@dateAdded": {}, + "datePlayed": "Reproduzido em", + "@datePlayed": {}, + "playCount": "Contagem de reproduções", + "@playCount": {}, + "premiereDate": "Data de Lançamento", + "@premiereDate": {}, + "productionYear": "Ano de Produção", + "@productionYear": {}, + "name": "Nome", + "@name": {}, + "random": "Aleatório", + "@random": {}, + "revenue": "Receita", + "@revenue": {}, + "runtime": "Duração", + "@runtime": {}, + "downloadMissingImages": "Baixar imagens ausentes", + "@downloadMissingImages": {}, + "downloadedMissingImages": "{count,plural, =0{Nenhuma imagem ausente baixada} =1{Baixada {count} imagem ausente} other{Baixadas{count} imagens ausentes}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrors": "Erros de transferência", + "@downloadErrors": {}, + "downloadCount": "{count,plural, =1{{count} baixada} other{{count} baixadas}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsCount": "{count,plural,=1{{count} item} other{{count} itens}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedImagesCount": "{count,plural,=1{{count} imagem} other{{count} imagens}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "dlFailed": "{count} falharam", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlEnqueued": "{count} enfileiradas", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "{count} em execução", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "Erros de Transferência", + "@downloadErrorsTitle": {}, + "noErrors": "Sem erros!", + "@noErrors": {}, + "songs": "Músicas", + "@songs": {}, + "startupError": "Algo deu errado na inicialização. O erro foi: {error}\n\nPor favor, crie um problema/issue em github.com/UnicornsOnLSD/finamp anexando uma captura de tela dessa página. Se esse erro persistir, limpe os dados para restaurar o aplicativo.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "internalExternalIpExplanation": "Se você quer acessar seu servidor Jellyfin remotamente, você precisa usar seu IP externo.\n\nSe seu servidor está em uma porta HTTP (80/443), você não precisa especificar a porta. Esse será o caso se o servidor estiver atrás de um proxy reverso.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "selectMusicLibraries": "Selecione a biblioteca de Músicas", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "couldNotFindLibraries": "Não foi possível encontrar nenhuma biblioteca.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "albums": "Álbuns", + "@albums": {}, + "artists": "Artistas", + "@artists": {}, + "unknownName": "Nome desconhecido", + "@unknownName": {}, + "albumArtist": "Artista do Álbum", + "@albumArtist": {}, + "criticRating": "Avaliação dos Críticos", + "@criticRating": {}, + "genres": "Gêneros", + "@genres": {}, + "artist": "Artista", + "@artist": {}, + "communityRating": "Avaliação da Comunidade", + "@communityRating": {}, + "dlComplete": "{count} concluídas", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadsAdded": "Transferências adicionadas.", + "@downloadsAdded": {}, + "logsCopied": "Relatórios copiados.", + "@logsCopied": {}, + "audioService": "Serviço de Áudio", + "@audioService": {}, + "shareLogs": "Compartilhar relatórios", + "@shareLogs": {}, + "addDownloadLocation": "Adicionar Localização de Transferências", + "@addDownloadLocation": {}, + "location": "Localização", + "@location": {}, + "enableTranscodingSubtitle": "A transmissão de músicas será transcodificada pelo servidor.", + "@enableTranscodingSubtitle": {}, + "songCount": "{count,plural,=1{{count} Música} other{{count} Músicas}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "editPlaylistNameTooltip": "Editar nome da lista de reprodução", + "@editPlaylistNameTooltip": {}, + "notAvailableInOfflineMode": "Não disponível no modo desconectado", + "@notAvailableInOfflineMode": {}, + "downloadedSongsWillNotBeDeleted": "Músicas transferidas não serão apagadas", + "@downloadedSongsWillNotBeDeleted": {}, + "areYouSure": "Tem certeza?", + "@areYouSure": {}, + "editPlaylistNameTitle": "Editar Nome da Lista de Reprodução", + "@editPlaylistNameTitle": {}, + "required": "Obrigatório", + "@required": {}, + "playlistNameUpdated": "Nome da lista de reprodução atualizado.", + "@playlistNameUpdated": {}, + "favourite": "Favorito", + "@favourite": {}, + "addDownloads": "Adicionar Transferências", + "@addDownloads": {}, + "logOut": "Sair", + "@logOut": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin usa AAC para transcodificação", + "@jellyfinUsesAACForTranscoding": {}, + "bitrate": "Taxa de bits", + "@bitrate": {}, + "downloadsDeleted": "Transferências apagadas.", + "@downloadsDeleted": {}, + "message": "Mensagem", + "@message": {}, + "updateButtonLabel": "ATUALIZAÇÃO", + "@updateButtonLabel": {}, + "addButtonLabel": "ADICIONAR", + "@addButtonLabel": {}, + "downloadLocations": "Locais de Transferências", + "@downloadLocations": {}, + "layoutAndTheme": "Composição & Tema", + "@layoutAndTheme": {}, + "enableTranscoding": "Ativar Transcodificação", + "@enableTranscoding": {}, + "customLocation": "Localização Customizada", + "@customLocation": {}, + "selectDirectory": "Selecione Diretório", + "@selectDirectory": {}, + "enterLowPriorityStateOnPause": "Entrar Estado de Baixa-Prioridade durante Pausa", + "@enterLowPriorityStateOnPause": {}, + "showCoverAsPlayerBackground": "Mostrar capas desfocadas como fundo do tocador", + "@showCoverAsPlayerBackground": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Indica se deve mostrar os artistas da música na tela do álbum se eles não forem diferentes dos artistas do álbum.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "bitrateSubtitle": "Uma taxa de bits mais alta resulta em áudio de maior qualidade, ao custo de maior largura de banda.", + "@bitrateSubtitle": {}, + "appDirectory": "Diretório de Aplicativos", + "@appDirectory": {}, + "unknownError": "Erro Desconhecido", + "@unknownError": {}, + "pathReturnSlashErrorMessage": "Caminhos que retornam \"/\" não podem ser usados", + "@pathReturnSlashErrorMessage": {}, + "theme": "Tema", + "@theme": {}, + "directoryMustBeEmpty": "Diretório deve estar vazio", + "@directoryMustBeEmpty": {}, + "customLocationsBuggy": "Localizações customizadas são extremamente defeituosas devidas à problems com permissões. Estou pensando em maneiras de consertar isso, mas por enquanto não recomendaria usá-las.", + "@customLocationsBuggy": {}, + "shuffleAllSongCount": "Contagem de Misturar Todas as Músicas", + "@shuffleAllSongCount": {}, + "enterLowPriorityStateOnPauseSubtitle": "Permite que a notificação seja dispensada quando pausado. Também permite ao Android terminar o serviço quando pausado.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "portrait": "Retrato", + "@portrait": {}, + "showTextOnGridView": "Mostrar texto na visualização de grade", + "@showTextOnGridView": {}, + "landscape": "Paisagem", + "@landscape": {}, + "gridCrossAxisCount": "{value} Contagem Eixo Cruzado da Grade", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "gridCrossAxisCountSubtitle": "Quantidade de ícones da grade para usar em cada fila quando {value}.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "showCoverAsPlayerBackgroundSubtitle": "Usar ou não usar arte de capas desfocadas como fundo da tela to tocador.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "hideSongArtistsIfSameAsAlbumArtists": "Esconder o nome dos artistas da música se for o mesmo que os artistas do álbum", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "light": "Claro", + "@light": {}, + "tabs": "Abas", + "@tabs": {}, + "setSleepTimer": "Configurar o Cronômetro de Sono", + "@setSleepTimer": {}, + "unknownArtist": "Artista Desconhecido", + "@unknownArtist": {}, + "showTextOnGridViewSubtitle": "Mostrar ou não o texto (título, artista, etc) na tela de música grade.", + "@showTextOnGridViewSubtitle": {}, + "system": "Sistema", + "@system": {}, + "dark": "Escuro", + "@dark": {}, + "noButtonLabel": "NÃO", + "@noButtonLabel": {}, + "sleepTimerTooltip": "Cronômetro de Sono", + "@sleepTimerTooltip": {}, + "noAlbum": "Nenhum Álbum", + "@noAlbum": {}, + "noItem": "Nenhum Ítem", + "@noItem": {}, + "direct": "DIRETO", + "@direct": {}, + "statusError": "ERRO DE CONDIÇÃO", + "@statusError": {}, + "addToQueue": "Adicionar à Fila", + "@addToQueue": {}, + "cancelSleepTimer": "Cancelar o Cronômetro de Sono?", + "@cancelSleepTimer": {}, + "addToPlaylistTitle": "Adicionar à Lista de Reprodução", + "@addToPlaylistTitle": {}, + "playlistCreated": "Lista de reprodução criada.", + "@playlistCreated": {}, + "downloaded": "TRANSFERIDO", + "@downloaded": {}, + "invalidNumber": "Número Inválido", + "@invalidNumber": {}, + "addToPlaylistTooltip": "Adicionar à lista de reprodução", + "@addToPlaylistTooltip": {}, + "createButtonLabel": "CRIAR", + "@createButtonLabel": {}, + "queue": "Fila", + "@queue": {}, + "replaceQueue": "Trocar a Fila", + "@replaceQueue": {}, + "yesButtonLabel": "SIM", + "@yesButtonLabel": {}, + "streaming": "TRANSMITINDO", + "@streaming": {}, + "instantMix": "Mistura Instantânea", + "@instantMix": {}, + "goToAlbum": "Ir para Álbum", + "@goToAlbum": {}, + "newPlaylist": "Nova Lista de Reprodução", + "@newPlaylist": {}, + "noArtist": "Nenhum Artista", + "@noArtist": {}, + "transcode": "TRANSCODIFICAR", + "@transcode": {}, + "startMixNoSongsArtist": "Aperte e segure num artista para adicionar ou remover da mistura antes de iniciá-la", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "startMixNoSongsAlbum": "Aperte e segure num álbum para adicionar ou remover da mistura antes de iniciá-la", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "budget": "Orçamento", + "@budget": {}, + "errorScreenError": "Um erro ocorreu acessando a lista de erros! A este ponto, você provavelmente deve criar um defeito (issue) no GitHub e apagar os dados do aplicativo", + "@errorScreenError": {}, + "error": "Erro", + "@error": {}, + "discNumber": "Disco {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "playButtonLabel": "REPRODUZIR", + "@playButtonLabel": {}, + "shuffleButtonLabel": "MISTURAR", + "@shuffleButtonLabel": {}, + "applicationLegalese": "Licenciado com a Licença Pública Mozilla 2.0. Código-fonte disponível em:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "transcoding": "Transcodificando", + "@transcoding": {}, + "failedToGetSongFromDownloadId": "Falha em adquirir música do ID da transferência", + "@failedToGetSongFromDownloadId": {}, + "stackTrace": "Traçado da Pilha", + "@stackTrace": {}, + "shuffleAllSongCountSubtitle": "Quantidade de músicas para carregar quando usando o botão misturar todas as músicas.", + "@shuffleAllSongCountSubtitle": {}, + "viewType": "Tipo de Visualização", + "@viewType": {}, + "viewTypeSubtitle": "Tipo de visualização para a tela de músicas", + "@viewTypeSubtitle": {}, + "list": "Lista", + "@list": {}, + "grid": "Grade", + "@grid": {}, + "removeFavourite": "Remover Favorito", + "@removeFavourite": {}, + "addFavourite": "Adicionar Favorito", + "@addFavourite": {}, + "addedToQueue": "Adicionado à fila.", + "@addedToQueue": {}, + "queueReplaced": "Fila trocada.", + "@queueReplaced": {}, + "startingInstantMix": "Iniciando mistura instantânea.", + "@startingInstantMix": {}, + "anErrorHasOccured": "Ocorreu um erro.", + "@anErrorHasOccured": {}, + "responseError": "{error} Código de condição {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "responseError401": "{error} Código de condição {statusCode}. Isto provavelmente significa que você usou um nome de usuário/senha errados, ou seu cliente não está mais autenticado.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "removeFromMix": "Remover da Mistura", + "@removeFromMix": {}, + "addToMix": "Adicionar à Mistura", + "@addToMix": {}, + "minutes": "Minutos", + "@minutes": {}, + "disableGesture": "Desativar gestos", + "@disableGesture": {}, + "disableGestureSubtitle": "Indica se deve desativar gestos.", + "@disableGestureSubtitle": {}, + "removeFromPlaylistTooltip": "Remover da playlist", + "@removeFromPlaylistTooltip": {}, + "removeFromPlaylistTitle": "Remover da Playlist", + "@removeFromPlaylistTitle": {}, + "removedFromPlaylist": "Removido da playlist.", + "@removedFromPlaylist": {}, + "bufferDuration": "Duração do buffer", + "@bufferDuration": {}, + "bufferDurationSubtitle": "Quanto o reprodutor deve armazenar de buffer, em segundos. Requer uma reinicialização.", + "@bufferDurationSubtitle": {}, + "redownloadedItems": "{count,plural, =0{Sem necessidade de baixar novamente.} =1{{count} item foi baixado novamente} other{{count} itens foram baixados novamente}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "language": "Idioma", + "@language": {}, + "resetTabs": "Redefinir abas", + "@resetTabs": {}, + "confirm": "Confirmar", + "@confirm": {}, + "showUncensoredLogMessage": "Este registro contém suas credenciais de acesso. Revelar?", + "@showUncensoredLogMessage": {}, + "insertedIntoQueue": "Inserido na fila.", + "@insertedIntoQueue": { + "description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end." + }, + "refresh": "ATUALIZAR", + "@refresh": {}, + "playNext": "Tocar Próximo", + "@playNext": { + "description": "Popup menu item title for inserting an item into the play queue after the currently-playing item." + }, + "noMusicLibrariesBody": "O Finamp não conseguiu encontrar nenhuma biblioteca de música. Certifique-se de que o seu servidor Jellyfin tenha pelo menos uma biblioteca com o tipo de conteúdo definido como \"Música\".", + "@noMusicLibrariesBody": {}, + "noMusicLibrariesTitle": "Sem Bibliotecas de Música", + "@noMusicLibrariesTitle": { + "description": "Title for message that shows on the views screen when no music libraries could be found." + }, + "interactions": "Interações", + "@interactions": {}, + "swipeInsertQueueNext": "Tocar Música Deslizada em Seguida", + "@swipeInsertQueueNext": {}, + "swipeInsertQueueNextSubtitle": "Adicionar uma música como próximo item na lista quando for deslizada, ao invés de adicionar no final.", + "@swipeInsertQueueNextSubtitle": {}, + "deleteDownloadsPrompt": "Você tem certeza que quer deletar {itemType, select, album{o álbum} playlist{a lista de reprodução} artist{o artista} genre{o gênero} track{a música} other{}} '{itemName}' desse dispositivo?", + "@deleteDownloadsPrompt": { + "placeholders": { + "itemName": { + "type": "String", + "example": "Abandon Ship" + }, + "itemType": { + "type": "String", + "example": "album" + } + }, + "description": "Confirmation prompt shown before deleting downloaded media from the local device, destructive action, doesn't affect the media on the server." + }, + "deleteDownloadsConfirmButtonText": "Deletar", + "@deleteDownloadsConfirmButtonText": { + "description": "Shown in the confirmation dialog for deleting downloaded media from the local device." + }, + "deleteDownloadsAbortButtonText": "Cancelar", + "@deleteDownloadsAbortButtonText": {}, + "syncDownloadedPlaylists": "Sincronizar listas de reprodução baixadas", + "@syncDownloadedPlaylists": {}, + "showFastScroller": "Mostrar rolagem rápida", + "@showFastScroller": {}, + "redesignBeta": "Teste o beta", + "@redesignBeta": {}, + "playbackOrderLinearTooltip": "Tocando em ordem. Aperte para ativar.", + "@playbackOrderLinearTooltip": {}, + "playbackOrderShuffledTooltip": "Embaralhando. Aperte para ativar.", + "@playbackOrderShuffledTooltip": {}, + "loopModeAllTooltip": "Repetir tudo. Aperte para ativar.", + "@loopModeAllTooltip": {}, + "loopModeOneTooltip": "Repetir uma. Aperte para ativar.", + "@loopModeOneTooltip": {}, + "loopModeNoneTooltip": "Sem repetição. Aperte para ativar.", + "@loopModeNoneTooltip": {}, + "skipToNext": "Pular para próxima música", + "@skipToNext": {}, + "skipToPrevious": "Pular para música anterior", + "@skipToPrevious": {}, + "deleteFromDevice": "Remover do dispositivo", + "@deleteFromDevice": {}, + "download": "Baixar", + "@download": {}, + "sync": "Sincronizar com servidor", + "@sync": {}, + "downloadArtist": "Baixar todos os álbuns de {artist}", + "@downloadArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "shuffleArtist": "Tocar todos os álbuns de {artist} em modo aleatório", + "@shuffleArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "playArtist": "Tocar todos os álbuns de {artist}", + "@playArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "togglePlayback": "Ativar reprodução", + "@togglePlayback": {}, + "about": "Sobre Finamp", + "@about": {} +} diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb new file mode 100644 index 0000000..a86b352 --- /dev/null +++ b/lib/l10n/app_ru.arb @@ -0,0 +1,539 @@ +{ + "serverUrl": "URL сервера", + "@serverUrl": {}, + "emptyServerUrl": "URL сервера не может быть пустым", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "urlStartWithHttps": "URL сервера должно начинаться с http:// или https://", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "urlTrailingSlash": "URL сервера не должен иметь / в конце", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "selectMusicLibraries": "Выбрать музыкальные библиотеки", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "couldNotFindLibraries": "Библиотеки не найдены.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "unknownName": "Неизвестное Имя", + "@unknownName": {}, + "songs": "Песни", + "@songs": {}, + "music": "Музыка", + "@music": {}, + "finamp": "Finamp", + "@finamp": {}, + "albumArtist": "Исполнитель Альбома", + "@albumArtist": {}, + "artist": "Исполнитель", + "@artist": {}, + "budget": "Бюджет", + "@budget": {}, + "communityRating": "Рейтинг Сообщества", + "@communityRating": {}, + "criticRating": "Рейтинг Критиков", + "@criticRating": {}, + "dateAdded": "Дата Добавления", + "@dateAdded": {}, + "datePlayed": "Дата Последнего Проигрывания", + "@datePlayed": {}, + "playCount": "Всего Проигрываний", + "@playCount": {}, + "premiereDate": "Дата Премьеры", + "@premiereDate": {}, + "name": "Имя", + "@name": {}, + "random": "Случайно", + "@random": {}, + "downloadErrors": "Ошибки Скачивания", + "@downloadErrors": {}, + "downloadedImagesCount": "{count,plural,=1{{count} изображение} few{{count} изображения} other{{count} изображений}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlComplete": "{count} завершено", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "failedToGetSongFromDownloadId": "Не удалось запросить песню с ID загрузки", + "@failedToGetSongFromDownloadId": {}, + "error": "Ошибка", + "@error": {}, + "discNumber": "Диск {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "playButtonLabel": "ИГРАТЬ", + "@playButtonLabel": {}, + "shuffleButtonLabel": "СЛУЧАЙНО", + "@shuffleButtonLabel": {}, + "songCount": "{count,plural,=1{{count} трек} few{{count} трека} other{{count} треков}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "editPlaylistNameTooltip": "Редактировать название плейлиста", + "@editPlaylistNameTooltip": {}, + "editPlaylistNameTitle": "Изменить название плейлиста", + "@editPlaylistNameTitle": {}, + "required": "Необходимо", + "@required": {}, + "updateButtonLabel": "ОБНОВИТЬ", + "@updateButtonLabel": {}, + "playlistNameUpdated": "Название плейлиста обновлено.", + "@playlistNameUpdated": {}, + "favourite": "В избранное", + "@favourite": {}, + "downloadsDeleted": "Загрузки удалены.", + "@downloadsDeleted": {}, + "downloadsAdded": "Добавлено в загрузки.", + "@downloadsAdded": {}, + "shareLogs": "Поделиться логами", + "@shareLogs": {}, + "logsCopied": "Логи скопированы.", + "@logsCopied": {}, + "message": "Сообщение", + "@message": {}, + "stackTrace": "Отслеживание стека", + "@stackTrace": {}, + "applicationLegalese": "Лицензия Mozilla Public License 2.0. Открытый код приложения доступен по ссылке:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "transcoding": "Транскодирование", + "@transcoding": {}, + "downloadLocations": "Расположение Загрузок", + "@downloadLocations": {}, + "audioService": "Аудиосервис", + "@audioService": {}, + "layoutAndTheme": "Макет и тема", + "@layoutAndTheme": {}, + "logOut": "Выйти", + "@logOut": {}, + "downloadedSongsWillNotBeDeleted": "Скачанные песни не будут удалены", + "@downloadedSongsWillNotBeDeleted": {}, + "areYouSure": "Вы уверены?", + "@areYouSure": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin использует AAC для транскодирования", + "@jellyfinUsesAACForTranscoding": {}, + "enableTranscoding": "Включить транскодирование", + "@enableTranscoding": {}, + "bitrate": "Битрейт", + "@bitrate": {}, + "appDirectory": "Папка Приложения", + "@appDirectory": {}, + "selectDirectory": "Выбрать папку", + "@selectDirectory": {}, + "unknownError": "Неизвестная Ошибка", + "@unknownError": {}, + "pathReturnSlashErrorMessage": "Невозможно указывать пути, использующие \"/\"", + "@pathReturnSlashErrorMessage": {}, + "directoryMustBeEmpty": "Папка должна быть пустой", + "@directoryMustBeEmpty": {}, + "customLocation": "Пользовательские Папки", + "@customLocation": {}, + "addDownloadLocation": "Добавить папку для загрузок", + "@addDownloadLocation": {}, + "customLocationsBuggy": "Пользовательские папки крайне нестабильны в связи с ошибками доступа. Я размышляю над тем, как это исправить, но пока я не рекомендую Вам их использовать.", + "@customLocationsBuggy": {}, + "enterLowPriorityStateOnPauseSubtitle": "Позволяет смахнуть уведомление на паузе. Также позволяет андроиду остановить сервис на паузе.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "shuffleAllSongCountSubtitle": "Количество песен для загрузки в режиме случайного проигрывания.", + "@shuffleAllSongCountSubtitle": {}, + "viewType": "Тип вида", + "@viewType": {}, + "viewTypeSubtitle": "Тип вида плеера", + "@viewTypeSubtitle": {}, + "list": "Список", + "@list": {}, + "grid": "Решётка", + "@grid": {}, + "portrait": "Портретный", + "@portrait": {}, + "landscape": "Альбомный", + "@landscape": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Отображать исполнителя трека на странице альбома, если он совпадает с исполнителем альбома.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "theme": "Тема оформления", + "@theme": {}, + "system": "Системная", + "@system": {}, + "light": "Светлая", + "@light": {}, + "dark": "Тёмная", + "@dark": {}, + "tabs": "Вкладки", + "@tabs": {}, + "cancelSleepTimer": "Выключить Таймер?", + "@cancelSleepTimer": {}, + "yesButtonLabel": "ДА", + "@yesButtonLabel": {}, + "noButtonLabel": "НЕТ", + "@noButtonLabel": {}, + "setSleepTimer": "Поставить Таймер", + "@setSleepTimer": {}, + "sleepTimerTooltip": "Таймер", + "@sleepTimerTooltip": {}, + "createButtonLabel": "СОЗДАТЬ", + "@createButtonLabel": {}, + "playlistCreated": "Плейлист создан.", + "@playlistCreated": {}, + "noItem": "Нет Элементов", + "@noItem": {}, + "noArtist": "Нет Исполнителя", + "@noArtist": {}, + "unknownArtist": "Неизвестный Исполнитель", + "@unknownArtist": {}, + "streaming": "СТРИМИНГ", + "@streaming": {}, + "downloaded": "ЗАГРУЖЕНО", + "@downloaded": {}, + "transcode": "ТРАНСКОДИРОВАНО", + "@transcode": {}, + "direct": "ПРЯМОЙ", + "@direct": {}, + "statusError": "ОШИБКА СТАТУСА", + "@statusError": {}, + "queue": "Очередь", + "@queue": {}, + "addToQueue": "Добавить в Очередь", + "@addToQueue": {}, + "replaceQueue": "Заменить Очередь", + "@replaceQueue": {}, + "instantMix": "Мгновенный Микс", + "@instantMix": {}, + "goToAlbum": "Перейти к Альбому", + "@goToAlbum": {}, + "addedToQueue": "Добавлено в очередь.", + "@addedToQueue": {}, + "queueReplaced": "Очередь заменена.", + "@queueReplaced": {}, + "startingInstantMix": "Запуск мгновенного микса.", + "@startingInstantMix": {}, + "responseError": "{error} Код состояния {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "responseError401": "{error} Код состояния {statusCode}. Возможно, вы использовали неверное имя пользователя/пароль или ваш клиент больше не авторизован.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "removeFromMix": "Удалить из Микса", + "@removeFromMix": {}, + "invalidNumber": "Неправильное Число", + "@invalidNumber": {}, + "addToPlaylistTooltip": "Добавить в плейлист", + "@addToPlaylistTooltip": {}, + "addToPlaylistTitle": "Добавить в Плейлист", + "@addToPlaylistTitle": {}, + "newPlaylist": "Новый Плейлист", + "@newPlaylist": {}, + "noAlbum": "Нет Альбома", + "@noAlbum": {}, + "addFavourite": "Добавить в Избранное", + "@addFavourite": {}, + "anErrorHasOccured": "Произошла ошибка.", + "@anErrorHasOccured": {}, + "clear": "Очистить", + "@clear": {}, + "settings": "Настройки", + "@settings": {}, + "removeFavourite": "Удалить из Избранного", + "@removeFavourite": {}, + "addToMix": "Добавить в Микс", + "@addToMix": {}, + "downloadMissingImages": "Скачать отсутствующие изображения", + "@downloadMissingImages": {}, + "offlineMode": "Автономный режим", + "@offlineMode": {}, + "startMix": "Начать Микс", + "@startMix": {}, + "internalExternalIpExplanation": "Чтобы получить удалённый доступ к серверу Jellyfin, используйте внешний IP-адрес.\n\nЕсли ваш сервер работает на портах HTTP(S) 80/443, указывать порт не нужно. Это обычно так, если сервер находится за обратным прокси-сервером.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "password": "Пароль", + "@password": {}, + "username": "Логин", + "@username": {}, + "logs": "Логи", + "@logs": {}, + "albums": "Альбомы", + "@albums": {}, + "artists": "Исполнители", + "@artists": {}, + "sortOrder": "Порядок сортировки", + "@sortOrder": {}, + "genres": "Жанры", + "@genres": {}, + "playlists": "Плейлисты", + "@playlists": {}, + "sortBy": "Сортировать по", + "@sortBy": {}, + "next": "Далее", + "@next": {}, + "startMixNoSongsArtist": "Чтобы добавить или удалить исполнителя из микса, нажмите и удерживайте его имя", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "startMixNoSongsAlbum": "Чтобы добавить или удалить альбом из микса, нажмите и удерживайте его", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "favourites": "Избранное", + "@favourites": {}, + "shuffleAll": "Перемешать всё", + "@shuffleAll": {}, + "downloads": "Загрузки", + "@downloads": {}, + "album": "Альбом", + "@album": {}, + "productionYear": "Год Создания", + "@productionYear": {}, + "revenue": "Доход", + "@revenue": {}, + "runtime": "Время выполнения", + "@runtime": {}, + "downloadedMissingImages": "{count,plural, =0{Не найдено отсутствующих изображений} =1{Скачано {count} отсутствующее изображение} few{Скачано {count} отсутствующих изображения} other{Скачано {count} отсутствующих изображений}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadCount": "{count,plural, =1{{count} загрузка} few{{count} загрузки} other{{count} загрузок}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsCount": "{count,plural,=1{{count} файл} few{{count} файла} other{{count} файлы}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlFailed": "{count} не удалось", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "dlEnqueued": "{count} в очереди", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "{count} скачиваются", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "Ошибки Загрузки", + "@downloadErrorsTitle": {}, + "addDownloads": "Добавить в загрузки", + "@addDownloads": {}, + "noErrors": "Ошибок нет!", + "@noErrors": {}, + "errorScreenError": "Ошибка при получении списка ошибок! Скорее всего, вам нужно очистить данные приложения и сообщить о данной ошибке на Github", + "@errorScreenError": {}, + "location": "Расположение", + "@location": {}, + "addButtonLabel": "ДОБАВИТЬ", + "@addButtonLabel": {}, + "enableTranscodingSubtitle": "Треки будут транскодированы сервером.", + "@enableTranscodingSubtitle": {}, + "notAvailableInOfflineMode": "Недоступно без интернета", + "@notAvailableInOfflineMode": {}, + "gridCrossAxisCountSubtitle": "Количество отображаемых элементов решётки построчно, когда {value}.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "enterLowPriorityStateOnPause": "Использовать режим низкого приоритета на Паузе", + "@enterLowPriorityStateOnPause": {}, + "bitrateSubtitle": "Высокий битрейт обеспечивает лучшее качество звука за счет большего потребления трафика.", + "@bitrateSubtitle": {}, + "shuffleAllSongCount": "Перемешать порядок всех песен", + "@shuffleAllSongCount": {}, + "gridCrossAxisCount": "{value} Количество элементов решётки", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "showTextOnGridView": "Отображать текст в режиме решётки", + "@showTextOnGridView": {}, + "showTextOnGridViewSubtitle": "Отображать или нет текст (название песни, исполнителя и т.д.) в режиме решётки.", + "@showTextOnGridViewSubtitle": {}, + "showCoverAsPlayerBackground": "Показывать размытую обложку на фоне", + "@showCoverAsPlayerBackground": {}, + "showCoverAsPlayerBackgroundSubtitle": "Отображать или нет размытую обложку альбома как фон в плеере.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "hideSongArtistsIfSameAsAlbumArtists": "Не отображать одинаковых исполнителей для трека и альбома", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "startupError": "Что-то пошло не так во время запуска приложения. Возникла ошибка: {error}\n\nПожалуйста, создайте \"issue\" на github.com/UnicornsOnLSD/finamp и приложите скриншот этой страницы. Если проблема будет повторяться, вы можете очистить данные приложения, чтобы сбросить его настройки.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "minutes": "Минуты", + "@minutes": {}, + "redownloadedItems": "{count,plural, =0{Не требуется повторная загрузка.} =1{Повторно загружен {count} элемент} few{Повторно загружены {count} элемента} other{Повторно загружено {count} элементов}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "disableGesture": "Отключить жесты", + "@disableGesture": {}, + "disableGestureSubtitle": "Стоит ли отключать жесты.", + "@disableGestureSubtitle": {}, + "bufferDuration": "Длительность буферизации", + "@bufferDuration": {}, + "bufferDurationSubtitle": "Размер буфера плеера (в секундах). Требуется перезапуск.", + "@bufferDurationSubtitle": {}, + "removedFromPlaylist": "Удалено из плейлиста.", + "@removedFromPlaylist": {}, + "removeFromPlaylistTooltip": "Удалить из плейлиста", + "@removeFromPlaylistTooltip": {}, + "removeFromPlaylistTitle": "Удалить из Плейлиста", + "@removeFromPlaylistTitle": {}, + "language": "Язык", + "@language": {}, + "confirm": "Подтвердить", + "@confirm": {}, + "showUncensoredLogMessage": "Этот лог содержит ваши данные для входа. Показать?", + "@showUncensoredLogMessage": {}, + "resetTabs": "Сбросить вкладки", + "@resetTabs": {}, + "insertedIntoQueue": "Вставлено в очередь.", + "@insertedIntoQueue": { + "description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end." + }, + "refresh": "ОБНОВИТЬ", + "@refresh": {}, + "playNext": "Воспроизвести Следующей", + "@playNext": { + "description": "Popup menu item title for inserting an item into the play queue after the currently-playing item." + }, + "noMusicLibrariesBody": "Finamp не обнаружил музыкальных библиотек. Убедитесь, что на вашем сервере Jellyfin есть хотя бы одна библиотека с типом контента \"Музыка\".", + "@noMusicLibrariesBody": {}, + "deleteDownloadsConfirmButtonText": "Удалить", + "@deleteDownloadsConfirmButtonText": { + "description": "Shown in the confirmation dialog for deleting downloaded media from the local device." + }, + "deleteDownloadsAbortButtonText": "Отмена", + "@deleteDownloadsAbortButtonText": {}, + "noMusicLibrariesTitle": "Нет музыкальных библиотек", + "@noMusicLibrariesTitle": { + "description": "Title for message that shows on the views screen when no music libraries could be found." + }, + "syncDownloadedPlaylists": "Синхронизировать загруженные плейлисты", + "@syncDownloadedPlaylists": {}, + "swipeInsertQueueNextSubtitle": "Разрешить вставлять песню следующей в очереди при свайпе в списке песен, а не добавлять ее в конец.", + "@swipeInsertQueueNextSubtitle": {}, + "deleteDownloadsPrompt": "Вы уверены, что хотите удалить {itemType, select, album{album} playlist{playlist} artist{artist} genre{genre} track{song} other{}} '{itemName}' с этого устройства?", + "@deleteDownloadsPrompt": { + "placeholders": { + "itemName": { + "type": "String", + "example": "Abandon Ship" + }, + "itemType": { + "type": "String", + "example": "album" + } + }, + "description": "Confirmation prompt shown before deleting downloaded media from the local device, destructive action, doesn't affect the media on the server." + }, + "interactions": "Взаимодействия", + "@interactions": {}, + "swipeInsertQueueNext": "Воспроизвести смахнутую песню следующей", + "@swipeInsertQueueNext": {}, + "redesignBeta": "Новый дизайн (бета)", + "@redesignBeta": {}, + "showFastScroller": "Показать быструю прокрутку", + "@showFastScroller": {} +} diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb new file mode 100644 index 0000000..739d630 --- /dev/null +++ b/lib/l10n/app_sv.arb @@ -0,0 +1,531 @@ +{ + "message": "Meddelande", + "@message": {}, + "noButtonLabel": "NEJ", + "@noButtonLabel": {}, + "responseError401": "{error} Statuskod {statusCode}. Detta betyder troligtvis att du använt fel användarnamn/lösenord, eller att din klientapplikation inte längre är autentiserad.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "serverUrl": "Länk Till Servern", + "@serverUrl": {}, + "emptyServerUrl": "Länken till servern får inte vara blank", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "albums": "Album", + "@albums": {}, + "songs": "Låtar", + "@songs": {}, + "favourites": "Favoriter", + "@favourites": {}, + "unknownName": "Okänt Namn", + "@unknownName": {}, + "clear": "Rensa", + "@clear": {}, + "selectMusicLibraries": "Välj Musikbibliotek", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "playlists": "Spellistor", + "@playlists": {}, + "album": "Album", + "@album": {}, + "budget": "Budget", + "@budget": {}, + "criticRating": "Kritikerbetyg", + "@criticRating": {}, + "premiereDate": "Premiärdatum", + "@premiereDate": {}, + "artist": "Artist", + "@artist": {}, + "settings": "Inställningar", + "@settings": {}, + "dateAdded": "Datum Tillagd", + "@dateAdded": {}, + "name": "Namn", + "@name": {}, + "productionYear": "Produktionsår", + "@productionYear": {}, + "revenue": "Intäkter", + "@revenue": {}, + "runtime": "Längd", + "@runtime": {}, + "downloadErrors": "Nedladdningsfel", + "@downloadErrors": {}, + "dlComplete": "{count} färdiga", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlEnqueued": "{count} i kö", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlFailed": "{count} misslyckade", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadCount": "{count,plural, =1{{count} nedladdning} other{{count} nedladdningar}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedImagesCount": "{count,plural,=1{{count} bild} other{{count} bilder}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "discNumber": "Skiva {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "Nedladdningsfel", + "@downloadErrorsTitle": {}, + "error": "Fel", + "@error": {}, + "failedToGetSongFromDownloadId": "Kunde inte hämta låt med hjälp av nedladdnings-ID", + "@failedToGetSongFromDownloadId": {}, + "favourite": "Lägg till som favorit", + "@favourite": {}, + "updateButtonLabel": "UPPDATERA", + "@updateButtonLabel": {}, + "songCount": "{count,plural,=1{{count} Låt} other{{count} Låtar}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "noErrors": "Inga fel!", + "@noErrors": {}, + "required": "Obligatoriskt", + "@required": {}, + "playlistNameUpdated": "Spellistans namn är ändrat.", + "@playlistNameUpdated": {}, + "downloadedSongsWillNotBeDeleted": "Nedladdade låtar kommer inte att raderas", + "@downloadedSongsWillNotBeDeleted": {}, + "addButtonLabel": "LÄGG TILL", + "@addButtonLabel": {}, + "logOut": "Logga Ut", + "@logOut": {}, + "transcoding": "Omkodning", + "@transcoding": {}, + "addDownloads": "Lägg Till Nedladdningar", + "@addDownloads": {}, + "layoutAndTheme": "Layout & Tema", + "@layoutAndTheme": {}, + "applicationLegalese": "Licensierad med Mozilla Public License 2.0. Källkoden finns att se på:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "location": "Plats", + "@location": {}, + "downloadsDeleted": "Nedladdningar raderade.", + "@downloadsDeleted": {}, + "areYouSure": "Är du säker?", + "@areYouSure": {}, + "unknownError": "Okänt Fel", + "@unknownError": {}, + "addDownloadLocation": "Lägg Till Nedladdningsplats", + "@addDownloadLocation": {}, + "bitrateSubtitle": "Högre bitrate (överföringshastighet) resulterar i högre kvalitet men förbrukar också mer data.", + "@bitrateSubtitle": {}, + "selectDirectory": "Välj Filmapp", + "@selectDirectory": {}, + "appDirectory": "Filmapp för Appen", + "@appDirectory": {}, + "directoryMustBeEmpty": "Filmappen måste vara tom", + "@directoryMustBeEmpty": {}, + "portrait": "Stående läge", + "@portrait": {}, + "landscape": "Liggande läge", + "@landscape": {}, + "list": "Lista", + "@list": {}, + "theme": "Tema", + "@theme": {}, + "light": "Ljust", + "@light": {}, + "system": "System", + "@system": {}, + "noAlbum": "Inget Album", + "@noAlbum": {}, + "addToPlaylistTooltip": "Lägg till i spellista", + "@addToPlaylistTooltip": {}, + "addToPlaylistTitle": "Lägg till i Spellista", + "@addToPlaylistTitle": {}, + "createButtonLabel": "SKAPA", + "@createButtonLabel": {}, + "setSleepTimer": "Ställ In Sovtimer", + "@setSleepTimer": {}, + "cancelSleepTimer": "Avbryt Sovtimer?", + "@cancelSleepTimer": {}, + "dark": "Mörkt", + "@dark": {}, + "yesButtonLabel": "JA", + "@yesButtonLabel": {}, + "unknownArtist": "Okänd Artist", + "@unknownArtist": {}, + "addToQueue": "Lägg till i Uppspelningskö", + "@addToQueue": {}, + "direct": "DIREKT", + "@direct": {}, + "goToAlbum": "Gå till Album", + "@goToAlbum": {}, + "addedToQueue": "Tillagd i uppspelningskön.", + "@addedToQueue": {}, + "addFavourite": "Lägg Till Favorit", + "@addFavourite": {}, + "anErrorHasOccured": "Ett fel har inträffat.", + "@anErrorHasOccured": {}, + "responseError": "{error} Statuskod {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "newPlaylist": "Ny Spellista", + "@newPlaylist": {}, + "music": "Musik", + "@music": {}, + "next": "Nästa", + "@next": {}, + "noArtist": "Ingen Artist", + "@noArtist": {}, + "notAvailableInOfflineMode": "Inte tillgänglig i offlineläge", + "@notAvailableInOfflineMode": {}, + "playButtonLabel": "SPELA UPP", + "@playButtonLabel": {}, + "artists": "Artister", + "@artists": {}, + "editPlaylistNameTooltip": "Ändra namn på spellista", + "@editPlaylistNameTooltip": {}, + "genres": "Genrer", + "@genres": {}, + "offlineMode": "Offlineläge", + "@offlineMode": {}, + "finamp": "Finamp", + "@finamp": {}, + "password": "Lösenord", + "@password": {}, + "pathReturnSlashErrorMessage": "Sökvägar som returnerar ett \"/\" kan inte användas", + "@pathReturnSlashErrorMessage": {}, + "downloaded": "NEDLADDAD", + "@downloaded": {}, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "internalExternalIpExplanation": "Om du vill ha åtkomst till din Jellyfin-server utanför hemmet, behöver du använda din externa IP-adress.\n\nOm din server är bakom en HTTP-port (80/443) så behöver du inte manuellt specificera en port. Detta är troligtvis fallet om din server finns bakom en omvänd proxy.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "editPlaylistNameTitle": "Ändra Namn På Spellista", + "@editPlaylistNameTitle": {}, + "grid": "Rutnät", + "@grid": {}, + "enableTranscodingSubtitle": "Koda om musikströmmar på servern.", + "@enableTranscodingSubtitle": {}, + "hideSongArtistsIfSameAsAlbumArtists": "Dölj låtartister om samma som albumartister", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "transcode": "OMKODA", + "@transcode": {}, + "enableTranscoding": "Aktivera Omkodning", + "@enableTranscoding": {}, + "albumArtist": "Albumartist", + "@albumArtist": {}, + "gridCrossAxisCountSubtitle": "Antal rutnäts-rutor per rad i {value}.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "downloadedMissingImages": "{count,plural, =0{Inga saknade bilder hittades} =1{Laddade ner {count} saknad bild} other{Laddade ner {count} saknade bilder}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "queue": "Kö", + "@queue": {}, + "bitrate": "Bitrate", + "@bitrate": {}, + "couldNotFindLibraries": "Kunde inte hitta några bibliotek.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "downloadsAdded": "Nedladdningar tillagda.", + "@downloadsAdded": {}, + "startupError": "Någonting gick fel under uppstart. Felet var: {error}\n\nSkapa gärna ett ärende på github.com/UnicornsOnLSD/finamp med en skärmdump av denna sida. Om problemet fortsätter kan du rensa appdatan för att återställa appen.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "invalidNumber": "Ogiltigt Nummer", + "@invalidNumber": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin använder AAC för omkodning", + "@jellyfinUsesAACForTranscoding": {}, + "datePlayed": "Datum Uppspelad", + "@datePlayed": {}, + "downloads": "Nedladdningar", + "@downloads": {}, + "errorScreenError": "Ett fel uppstod när listan över fel skulle hämtas! Vid det här laget bör du förmodligen bara skapa ett problem på GitHub och ta bort appdata", + "@errorScreenError": {}, + "shuffleAllSongCountSubtitle": "Antal låtar att ladda vid användning av shuffle-knappen.", + "@shuffleAllSongCountSubtitle": {}, + "shuffleAll": "Shuffle (alla)", + "@shuffleAll": {}, + "shuffleButtonLabel": "SHUFFLE", + "@shuffleButtonLabel": {}, + "audioService": "Ljudtjänst", + "@audioService": {}, + "urlTrailingSlash": "URL:en får inte innehålla ett avslutande snedstreck", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "urlStartWithHttps": "URL:en måste börja med antingen http:// eller https://", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "username": "Användarnamn", + "@username": {}, + "logs": "Loggar", + "@logs": {}, + "random": "Slumpmässig", + "@random": {}, + "shareLogs": "Dela loggar", + "@shareLogs": {}, + "logsCopied": "Loggar kopierade.", + "@logsCopied": {}, + "showTextOnGridView": "Visa text i rutnätsvy", + "@showTextOnGridView": {}, + "showTextOnGridViewSubtitle": "Huruvida text (titel, artist etc.) ska visas på rutnäts-musikskärmen.", + "@showTextOnGridViewSubtitle": {}, + "tabs": "Flikar", + "@tabs": {}, + "sleepTimerTooltip": "Sovtimer", + "@sleepTimerTooltip": {}, + "playlistCreated": "Spellista skapad.", + "@playlistCreated": {}, + "replaceQueue": "Ersätt Uppspelningskö", + "@replaceQueue": {}, + "queueReplaced": "Uppspelningskö ändrad.", + "@queueReplaced": {}, + "removeFavourite": "Ta Bort Favorit", + "@removeFavourite": {}, + "sortBy": "Sortera efter", + "@sortBy": {}, + "sortOrder": "Sorteringsordning", + "@sortOrder": {}, + "communityRating": "Allmänhetens Betyg", + "@communityRating": {}, + "downloadMissingImages": "Hämta saknade bilder", + "@downloadMissingImages": {}, + "downloadedItemsCount": "{count,plural,=1{{count} objekt} other{{count} objekt}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "{count} pågående", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadLocations": "Platser För Nedladdningar", + "@downloadLocations": {}, + "customLocation": "Anpassad Plats", + "@customLocation": {}, + "customLocationsBuggy": "Det finns en hel del buggar relaterade till anpassade platser just nu på grund av krångel med användarbehörigheter. Jag försöker lista ut hur det bäst ska lösas, men i nuläget rekommenderar jag att inte använda dem.", + "@customLocationsBuggy": {}, + "startMix": "Starta Mix", + "@startMix": {}, + "startMixNoSongsArtist": "Tryck länge på en artist för att lägga till eller ta bort den från mix-byggaren innan du startar en mix", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "startMixNoSongsAlbum": "Tryck länge på ett album för att lägga till eller ta bort det från mix-byggaren innan du startar en mix", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "playCount": "Antal Uppspelningar", + "@playCount": {}, + "stackTrace": "Stackspår", + "@stackTrace": {}, + "enterLowPriorityStateOnPause": "Gå in i lågprioritetstillstånd vid paus", + "@enterLowPriorityStateOnPause": {}, + "enterLowPriorityStateOnPauseSubtitle": "Låter aviseringen svepas bort när den är pausad. Tillåter även Android att döda tjänsten när den är pausad.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "minutes": "Minuter", + "@minutes": {}, + "startingInstantMix": "Startar snabbmix.", + "@startingInstantMix": {}, + "removeFromMix": "Ta bort från blandning", + "@removeFromMix": {}, + "addToMix": "Lägg till i mixen", + "@addToMix": {}, + "shuffleAllSongCount": "Blanda alla låtar", + "@shuffleAllSongCount": {}, + "viewTypeSubtitle": "Visningstyp för musikskärmen", + "@viewTypeSubtitle": {}, + "showCoverAsPlayerBackground": "Visa suddigt omslag som spelarbakgrund", + "@showCoverAsPlayerBackground": {}, + "showCoverAsPlayerBackgroundSubtitle": "Om du vill använda suddig omslagsbild som bakgrund på spelarskärmen.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Om låtartister ska visas på albumskärmen om de inte skiljer sig från albumartister.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "streaming": "STRÖMNING", + "@streaming": {}, + "statusError": "STATUSFEL", + "@statusError": {}, + "instantMix": "Snabbmix", + "@instantMix": {}, + "noItem": "Inget objekt", + "@noItem": {}, + "removedFromPlaylist": "Borttagen från spellista.", + "@removedFromPlaylist": {}, + "viewType": "Visningstyp", + "@viewType": {}, + "removeFromPlaylistTooltip": "Ta bort från spellista", + "@removeFromPlaylistTooltip": {}, + "bufferDuration": "Buffertlängd", + "@bufferDuration": {}, + "showUncensoredLogMessage": "Denna logg innehåller din inloggningsinformation. Visa?", + "@showUncensoredLogMessage": {}, + "gridCrossAxisCount": "{value} Antal rutnätskorsaxel", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "removeFromPlaylistTitle": "Ta bort från spellista", + "@removeFromPlaylistTitle": {}, + "bufferDurationSubtitle": "Hur mycket spelaren ska buffra, i sekunder. Kräver omstart.", + "@bufferDurationSubtitle": {}, + "insertedIntoQueue": "Insatt i kön.", + "@insertedIntoQueue": { + "description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end." + }, + "refresh": "UPPDATERA", + "@refresh": {}, + "confirm": "Bekräfta", + "@confirm": {}, + "language": "Språk", + "@language": {}, + "playNext": "Spela Nästa", + "@playNext": { + "description": "Popup menu item title for inserting an item into the play queue after the currently-playing item." + }, + "redownloadedItems": "{count,plural, =0{Inga omnedladdningar behövs..} =1{Nedladdat igen {count} item} other{Nedladdat igen{count} items}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "resetTabs": "Återställ flikar", + "@resetTabs": {}, + "noMusicLibrariesBody": "Finamp kunde inte hitta några musikbibliotek. Se till att din Jellyfin-server innehåller minst ett bibliotek med innehållstypen inställd på \"Musik\".", + "@noMusicLibrariesBody": {}, + "disableGesture": "Inaktivera gester", + "@disableGesture": {}, + "noMusicLibrariesTitle": "Inga musikbibliotek", + "@noMusicLibrariesTitle": { + "description": "Title for message that shows on the views screen when no music libraries could be found." + }, + "disableGestureSubtitle": "Huruvida man ska inaktivera gester.", + "@disableGestureSubtitle": {}, + "deleteDownloadsConfirmButtonText": "Ta bort", + "@deleteDownloadsConfirmButtonText": { + "description": "Shown in the confirmation dialog for deleting downloaded media from the local device." + }, + "deleteDownloadsPrompt": "Är du säker på att du vill ta bort {itemType, select, album{album} playlist{playlist} artist{artist} genre{genre} track{song} other{}} '{itemName}' från den här enheten?", + "@deleteDownloadsPrompt": { + "placeholders": { + "itemName": { + "type": "String", + "example": "Abandon Ship" + }, + "itemType": { + "type": "String", + "example": "album" + } + }, + "description": "Confirmation prompt shown before deleting downloaded media from the local device, destructive action, doesn't affect the media on the server." + }, + "deleteDownloadsAbortButtonText": "Avbryt", + "@deleteDownloadsAbortButtonText": {}, + "syncDownloadedPlaylists": "Synkronisera nedladdade spellistor", + "@syncDownloadedPlaylists": {}, + "interactions": "Interaktioner", + "@interactions": {} +} diff --git a/lib/l10n/app_sw.arb b/lib/l10n/app_sw.arb new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/lib/l10n/app_sw.arb @@ -0,0 +1 @@ +{} diff --git a/lib/l10n/app_szl.arb b/lib/l10n/app_szl.arb new file mode 100644 index 0000000..632a91b --- /dev/null +++ b/lib/l10n/app_szl.arb @@ -0,0 +1,460 @@ +{ + "@@locale": "szl", + "serverUrl": "Adresa URL ôd serwera", + "@serverUrl": {}, + "urlStartWithHttps": "URL musi sie zaczynać ôd http:// abo https://", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "urlTrailingSlash": "URL niy może sie kōńczyć znakym „/”", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "username": "Miano ôd używocza", + "@username": {}, + "password": "Hasło", + "@password": {}, + "next": "Dalij", + "@next": {}, + "selectMusicLibraries": "Wybier bibliotyki ze muzykōm", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "couldNotFindLibraries": "Niy szło znojś żodnych bibliotyk.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "unknownName": "Niyznōme miano", + "@unknownName": {}, + "songs": "Śpiywki", + "@songs": {}, + "albums": "Albumy", + "@albums": {}, + "artists": "Artyścio", + "@artists": {}, + "genres": "Gatōnki", + "@genres": {}, + "startMixNoSongsArtist": "Prziciś dugo na artysty, żeby go dodać abo wymazać ze budowanio miksu, podwiela go puścisz", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "music": "Muzyka", + "@music": {}, + "internalExternalIpExplanation": "Jeźli chcesz mieć zdalny przistymp do swojigo serwera Jellyfin, to potrzebujesz zewnyntrznego IP..\n\nJeźli twōj serwer je na porcie HTTP (80/443), to niy musisz podować portu. Tak nojczyńścij je, jak serwer je za reverse proxy.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "dateAdded": "Data dodanio", + "@dateAdded": {}, + "dlRunning": "{count} two", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsCount": "{count,plural,=1{{count} elymynt} other{{count} elymyntōw}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "required": "Wymogane", + "@required": {}, + "updateButtonLabel": "SPAMIYNTEJ", + "@updateButtonLabel": {}, + "playlistNameUpdated": "Miano ôd playlisty spamiyntane.", + "@playlistNameUpdated": {}, + "favourite": "Ôblubiōny", + "@favourite": {}, + "downloadsDeleted": "Pobrane skasowane.", + "@downloadsDeleted": {}, + "addDownloads": "Dodej pobrane", + "@addDownloads": {}, + "message": "Wiadōmość", + "@message": {}, + "stackTrace": "Ślod stōsa", + "@stackTrace": {}, + "notAvailableInOfflineMode": "Niyprzistympne we trybie offline", + "@notAvailableInOfflineMode": {}, + "logOut": "Ôdloguj", + "@logOut": {}, + "customLocationsBuggy": "Ze włosnymi lokacyjami je moc problymōw skuli turbacyji ze uprawniyniami. Sōm rozwożane spōsoby sprawiynio tego, ale teroz jejich używanie niy ma rekōmyndowane.", + "@customLocationsBuggy": {}, + "enterLowPriorityStateOnPause": "Przi pauzie przejdź we stōn niskigo priorytetu", + "@enterLowPriorityStateOnPause": {}, + "enterLowPriorityStateOnPauseSubtitle": "Kej to je włōnczōne, to powiadōmiynie może być ôdpōnkniynte przi pauzie. Włōnczynie tego przizwolo tyż Androidowi zabic usuga przi pauzie.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "shuffleAllSongCount": "Liczba śpiywek przi miyszaniu wszyskich", + "@shuffleAllSongCount": {}, + "shuffleAllSongCountSubtitle": "Liczba śpiywek do zaladowanio po użyciu knefla miyszanio wszyskich śpiywek.", + "@shuffleAllSongCountSubtitle": {}, + "viewType": "Widok", + "@viewType": {}, + "viewTypeSubtitle": "Widok panelu muzyki", + "@viewTypeSubtitle": {}, + "list": "Lista", + "@list": {}, + "grid": "Krotka", + "@grid": {}, + "portrait": "Do blaju", + "@portrait": {}, + "yesButtonLabel": "JA", + "@yesButtonLabel": {}, + "noButtonLabel": "NIY", + "@noButtonLabel": {}, + "invalidNumber": "Niynoleżno liczba", + "@invalidNumber": {}, + "noAlbum": "Brak albumu", + "@noAlbum": {}, + "noItem": "Brak elymyntu", + "@noItem": {}, + "noArtist": "Brak artysty", + "@noArtist": {}, + "unknownArtist": "Niyznōmy artysta", + "@unknownArtist": {}, + "streaming": "STRUMIYNIOWANIE", + "@streaming": {}, + "downloaded": "POBRANE", + "@downloaded": {}, + "emptyServerUrl": "Adresa URL ôd serwera niy może być prōzno", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "playlists": "Playlisty", + "@playlists": {}, + "logs": "Logi", + "@logs": {}, + "startMix": "Zacznij mix", + "@startMix": {}, + "premiereDate": "Datōm prymiery", + "@premiereDate": {}, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "startMixNoSongsAlbum": "Prziciś dugo na album, żeby go dodać abo wymazać ze budowanio miksu, podwiela go puścisz", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "clear": "Wysnoż", + "@clear": {}, + "shuffleAll": "Miyszej wszysko", + "@shuffleAll": {}, + "albumArtist": "Artysta albumu", + "@albumArtist": {}, + "artist": "Artysta", + "@artist": {}, + "runtime": "Czas twanio", + "@runtime": {}, + "favourites": "Ôblubiōne", + "@favourites": {}, + "finamp": "Finamp", + "@finamp": {}, + "downloads": "Pobrane", + "@downloads": {}, + "settings": "Sztalōnki", + "@settings": {}, + "offlineMode": "Tryb offline", + "@offlineMode": {}, + "sortOrder": "Porzōndek zortowanio", + "@sortOrder": {}, + "sortBy": "Zortuj podug", + "@sortBy": {}, + "album": "Album", + "@album": {}, + "budget": "Budżet", + "@budget": {}, + "communityRating": "Ôcyna ôd społeczności", + "@communityRating": {}, + "random": "Lōsowo", + "@random": {}, + "criticRating": "Ôcyna ôd krytykōw", + "@criticRating": {}, + "playCount": "Liczba grań", + "@playCount": {}, + "datePlayed": "Datōm granio", + "@datePlayed": {}, + "productionYear": "Rok produkcyje", + "@productionYear": {}, + "downloadMissingImages": "Pobier ôbrazy, co brakujōm", + "@downloadMissingImages": {}, + "downloadErrors": "Felery pobiyranio", + "@downloadErrors": {}, + "downloadCount": "{count,plural, =1{{count} pobranie} other{{count} pobrań}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "songCount": "{count,plural,=1{{count} śpiywka} other{{count} śpiywek}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadsAdded": "Pobrane dodane.", + "@downloadsAdded": {}, + "unknownError": "Niyznōmy feler", + "@unknownError": {}, + "name": "Nazwa", + "@name": {}, + "revenue": "Dochōd", + "@revenue": {}, + "failedToGetSongFromDownloadId": "Niy szło znojś śpiywki ze ID pobiyranio", + "@failedToGetSongFromDownloadId": {}, + "error": "Feler", + "@error": {}, + "discNumber": "Dysk {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "playButtonLabel": "GREJ", + "@playButtonLabel": {}, + "layoutAndTheme": "Ukłod i tymat", + "@layoutAndTheme": {}, + "directoryMustBeEmpty": "Katalog musi być prōzny", + "@directoryMustBeEmpty": {}, + "downloadedMissingImages": "{count,plural, =0{Niy brakuje żodnych ôbrazōw} =1{Pobrany {count} ôbroz, co brakuje} other{Liczba pobranych ôbrazōw, co brakujōm: {count}}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedImagesCount": "{count,plural,=1{{count} ôbroz} other{{count} ôbrazōw}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlComplete": "{count} podarzōne", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlEnqueued": "{count} we raji", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "editPlaylistNameTooltip": "Edytowanie miana ôd playlisty", + "@editPlaylistNameTooltip": {}, + "logsCopied": "Logi skopiowane.", + "@logsCopied": {}, + "downloadedSongsWillNotBeDeleted": "Pobrane śpiywki niy bydōm wymazane", + "@downloadedSongsWillNotBeDeleted": {}, + "customLocation": "Włosno lokacyjo", + "@customLocation": {}, + "selectDirectory": "Wybier katalog", + "@selectDirectory": {}, + "dlFailed": "{count} niypodarzōne", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "Felery pobiyranio", + "@downloadErrorsTitle": {}, + "noErrors": "Bez felerōw!", + "@noErrors": {}, + "errorScreenError": "Doszło do feleru przi pobiyraniu wykazu felerōw! We takim przipodku chyba nojlepij to zgłosić na GitHubie i skasować dane ôd aplikacyje", + "@errorScreenError": {}, + "shuffleButtonLabel": "LŌSOWO", + "@shuffleButtonLabel": {}, + "editPlaylistNameTitle": "Edycyjo miana ôd playlisty", + "@editPlaylistNameTitle": {}, + "audioService": "Usuga audio", + "@audioService": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin używo AAC do transkodowanio", + "@jellyfinUsesAACForTranscoding": {}, + "enableTranscodingSubtitle": "Jeźli zaznaczōne, to strumiynie muzyki bydōm transkodowane na serwerze.", + "@enableTranscodingSubtitle": {}, + "addDownloadLocation": "Dodej lokacyjo pobiyranio", + "@addDownloadLocation": {}, + "location": "Lokalizacyjo", + "@location": {}, + "addButtonLabel": "DODEJ", + "@addButtonLabel": {}, + "shareLogs": "Podziel sie logami", + "@shareLogs": {}, + "applicationLegalese": "Na licyncyji Mozilla Public License 2.0. Zdrzōdłowy kod je przistympny na:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "transcoding": "Transkodowanie", + "@transcoding": {}, + "downloadLocations": "Lokacyje pobiyranio", + "@downloadLocations": {}, + "areYouSure": "Na zicher?", + "@areYouSure": {}, + "enableTranscoding": "Włōncz transkodowanie", + "@enableTranscoding": {}, + "bitrate": "Bitrate", + "@bitrate": {}, + "bitrateSubtitle": "Wyższy bitrate dowo wyższo jakość audio kosztym srogszego transferu danych.", + "@bitrateSubtitle": {}, + "appDirectory": "Katalog ôd aplikacyje", + "@appDirectory": {}, + "pathReturnSlashErrorMessage": "Ściyżek, co skazujōm „/”, niy idzie używać", + "@pathReturnSlashErrorMessage": {}, + "gridCrossAxisCount": "{value} – liczba elymyntōw we rzyńdzie krotki", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "landscape": "Poziōmo", + "@landscape": {}, + "gridCrossAxisCountSubtitle": "Liczba użytych kachelek we rzyńdzie krotki: {value}.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "showTextOnGridView": "Pokazuj tekst we widoku krotki", + "@showTextOnGridView": {}, + "showTextOnGridViewSubtitle": "Sztaluje, jeźli pokazować tekst (tytuł, artysta itd.) we panelu krotki elymyntōw.", + "@showTextOnGridViewSubtitle": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Sztaluje, jeźli kryć artysty śpiywki, jeźli je taki sōm jak artysta albumu.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "showCoverAsPlayerBackground": "Pokazuj rozmazano ôkładzina za tło ôd przegrowacza", + "@showCoverAsPlayerBackground": {}, + "showCoverAsPlayerBackgroundSubtitle": "Sztaluje, jeźli pokazować rozmazano ôkładzina za tło ôd przegrowacza.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "theme": "Tymat", + "@theme": {}, + "system": "Systymowy", + "@system": {}, + "light": "Jasny", + "@light": {}, + "tabs": "Zokłodki", + "@tabs": {}, + "hideSongArtistsIfSameAsAlbumArtists": "Skryj artysty śpiywki, jeźli je taki sōm jak artysta albumu", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "dark": "Ciymny", + "@dark": {}, + "statusError": "FELER STATUSU", + "@statusError": {}, + "queue": "Raja", + "@queue": {}, + "setSleepTimer": "Nasztaluj ôdliczanie do zastawiynio", + "@setSleepTimer": {}, + "cancelSleepTimer": "Ôdwołać ôdliczanie do zastawiynio?", + "@cancelSleepTimer": {}, + "createButtonLabel": "STWŌRZ", + "@createButtonLabel": {}, + "playlistCreated": "Playlista stworzōno.", + "@playlistCreated": {}, + "direct": "BEZPOSTRZEDNIE", + "@direct": {}, + "instantMix": "Wartki mix", + "@instantMix": {}, + "anErrorHasOccured": "Doszło do feleru.", + "@anErrorHasOccured": {}, + "responseError": "{error} Kod statusu {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "sleepTimerTooltip": "Ôdliczanie do zastawiynio", + "@sleepTimerTooltip": {}, + "addToPlaylistTooltip": "Dodej do playlisty", + "@addToPlaylistTooltip": {}, + "addToPlaylistTitle": "Dodej do playlisty", + "@addToPlaylistTitle": {}, + "newPlaylist": "Nowo playlista", + "@newPlaylist": {}, + "transcode": "TRANSKODOWANIE", + "@transcode": {}, + "replaceQueue": "Zastōmp raja", + "@replaceQueue": {}, + "startupError": "Coś poszło źle przi ôtwiyraniu aplikacyje. Feler to: {error}\n\nZgłoś to na github.com/UnicornsOnLSD/finamp ze zopisym ekranu tyj strōny. Jeźli ta strōna niy przestowo sie pokazować, to wymaż dane aplikacyje, żeby ja zresetować.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "addToQueue": "Dodej do raje", + "@addToQueue": {}, + "goToAlbum": "Idź do albumu", + "@goToAlbum": {}, + "removeFavourite": "Wymaż ze ôblubiōnych", + "@removeFavourite": {}, + "addFavourite": "Dodej do ôblubiōnych", + "@addFavourite": {}, + "addedToQueue": "Dodane do raje.", + "@addedToQueue": {}, + "startingInstantMix": "Zaczynanie wartkigo miksu.", + "@startingInstantMix": {}, + "queueReplaced": "Raja zastōmpiōno.", + "@queueReplaced": {}, + "addToMix": "Dodej do miksu", + "@addToMix": {}, + "responseError401": "{error} Kod statusu {statusCode}. To bezma ôznaczo, że było użyto niynoleżne miano ôd używocza abo hasło, abo tyż twōj kliynt już niy ma autoryzowany.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "removeFromMix": "Wymaż ze miksu", + "@removeFromMix": {} +} diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb new file mode 100644 index 0000000..4395a21 --- /dev/null +++ b/lib/l10n/app_th.arb @@ -0,0 +1,533 @@ +{ + "showCoverAsPlayerBackgroundSubtitle": "แสดงหรือไม่แสดงภาพเบลอของปกเพลงเป็นพื้นหลังของตัวเล่นเพลง", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "music": "เพลง", + "@music": {}, + "clear": "เคลียร์", + "@clear": {}, + "hideSongArtistsIfSameAsAlbumArtists": "ซ่อนศิลปินของเพลงหากเป็นชื่อเดียวกับศิลปินของอัลบั้ม", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "favourites": "รายการโปรด", + "@favourites": {}, + "setSleepTimer": "ตั้งเวลาปิด", + "@setSleepTimer": {}, + "minutes": "นาที", + "@minutes": {}, + "sleepTimerTooltip": "ตั้งเวลาปิด", + "@sleepTimerTooltip": {}, + "addToPlaylistTitle": "เพิ่มไปยังเพลย์ลิสต์", + "@addToPlaylistTitle": {}, + "newPlaylist": "สร้างเพลย์ลิสต์ใหม่", + "@newPlaylist": {}, + "noArtist": "ไม่มีศิลปิน", + "@noArtist": {}, + "removeFavourite": "นำออกจากรายการโปรด", + "@removeFavourite": {}, + "statusError": "สถานะผิดพลาด", + "@statusError": {}, + "queueReplaced": "คิวถูกแทนที่แล้ว", + "@queueReplaced": {}, + "responseError": "{error} รหัสข้อผิดพลาด {statusCode}", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "serverUrl": "URL ของเซิร์ฟเวอร์", + "@serverUrl": {}, + "logs": "ล็อก", + "@logs": {}, + "next": "ถัดไป", + "@next": {}, + "selectMusicLibraries": "เลือกคลังเพลง", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "artists": "ศิลปิน", + "@artists": {}, + "genres": "แนวเพลง", + "@genres": {}, + "startMixNoSongsAlbum": "แตะค้างที่อัลบั้มเพื่อเพิ่มหรือนำออกจากตัวสร้างมิกซ์ก่อนที่จะเริ่มมิกซ์", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "albumArtist": "ศิลปินของอัลบั้ม", + "@albumArtist": {}, + "artist": "ศิลปิน", + "@artist": {}, + "budget": "งบ", + "@budget": {}, + "communityRating": "เรตติ้งจากชุมชน", + "@communityRating": {}, + "criticRating": "เรตติ้งจาก Critic", + "@criticRating": {}, + "datePlayed": "วันที่เล่น", + "@datePlayed": {}, + "premiereDate": "วันที่เปิดตัว", + "@premiereDate": {}, + "productionYear": "ปีที่สร้าง", + "@productionYear": {}, + "name": "ชื่อ", + "@name": {}, + "revenue": "รายได้", + "@revenue": {}, + "downloadMissingImages": "ดาวน์โหลดไฟล์ภาพที่ขาด", + "@downloadMissingImages": {}, + "downloadCount": "{count,plural, =1{ดาวน์โหลดแล้ว {count}} other{ดาวน์โหลดแล้ว {count}}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "dlComplete": "เสร็จแล้ว {count}", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlFailed": "ล้มเหลว {count}", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlEnqueued": "เข้าคิว {count}", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "noErrors": "ไม่มีข้อผิดพลาด!", + "@noErrors": {}, + "favourite": "รายการโปรด", + "@favourite": {}, + "downloadsDeleted": "ลบการดาวน์โหลดแล้ว", + "@downloadsDeleted": {}, + "addDownloads": "เพิ่มการดาวน์โหลด", + "@addDownloads": {}, + "location": "สถานที่", + "@location": {}, + "downloadsAdded": "เพิ่มการดาวน์โหลดแล้ว", + "@downloadsAdded": {}, + "addButtonLabel": "เพิ่ม", + "@addButtonLabel": {}, + "shareLogs": "แชร์ล็อก", + "@shareLogs": {}, + "logsCopied": "คัดลอกล็อกแล้ว", + "@logsCopied": {}, + "transcoding": "การแปลงไฟล์", + "@transcoding": {}, + "downloadLocations": "ตำแหน่งที่ดาวน์โหลด", + "@downloadLocations": {}, + "audioService": "บริการเสียง", + "@audioService": {}, + "stackTrace": "ตามรอย", + "@stackTrace": {}, + "layoutAndTheme": "หน้าตา และ ธีม", + "@layoutAndTheme": {}, + "notAvailableInOfflineMode": "ไม่พร้อมใช้งานในโหมดออฟไลน์", + "@notAvailableInOfflineMode": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin ใช้รหัส AAC สำหรับการแปลงไฟล์", + "@jellyfinUsesAACForTranscoding": {}, + "enableTranscoding": "เปิดใช้งานการแปลงไฟล์", + "@enableTranscoding": {}, + "enableTranscodingSubtitle": "การแปลงไฟล์เพลงจะถูกดำเนินการจากฝั่งเซิร์ฟเวอร์", + "@enableTranscodingSubtitle": {}, + "bitrate": "บิทเรต", + "@bitrate": {}, + "customLocation": "ตำแหน่งแบบเลือกเอง", + "@customLocation": {}, + "appDirectory": "โฟลเดอร์ของแอป", + "@appDirectory": {}, + "directoryMustBeEmpty": "โฟลเดอร์นั้นต้องว่าง", + "@directoryMustBeEmpty": {}, + "enterLowPriorityStateOnPause": "เมื่อหยุดพักจะเข้าสู่โหมดความสำคัญต่ำ", + "@enterLowPriorityStateOnPause": {}, + "shuffleAllSongCount": "จำนวนครั้งที่สุ่มเพลงทั้งหมด", + "@shuffleAllSongCount": {}, + "shuffleAllSongCountSubtitle": "จำนวนเพลงที่โหลดขึ้นมาเมื่อใช้ปุ่ม สุ่มทั้งหมด", + "@shuffleAllSongCountSubtitle": {}, + "viewTypeSubtitle": "ประเภทการแสดงผลของหน้าจอเพลง", + "@viewTypeSubtitle": {}, + "list": "รายการ", + "@list": {}, + "grid": "ตาราง", + "@grid": {}, + "portrait": "แนวตั้ง", + "@portrait": {}, + "landscape": "แนวนอน", + "@landscape": {}, + "gridCrossAxisCount": "จำนวนรายการใน {value}", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "gridCrossAxisCountSubtitle": "จำนวนของไอเท็มในตารางต่อแถวของ {value}", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "showTextOnGridView": "แสดงตัวอักษรในโหมดตาราง", + "@showTextOnGridView": {}, + "showCoverAsPlayerBackground": "แสดงภาพเบลอของปกเพลงเป็นภาพพื้นหลังของตัวเล่นเพลง", + "@showCoverAsPlayerBackground": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "แสดงหรือไม่แสดงชื่อศิลปินเพลงบนหน้าจออัลบั้มหากทั้งสองชื่อไม่ต่างกัน", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "disableGesture": "ปิดการใช้งาน gestures", + "@disableGesture": {}, + "disableGestureSubtitle": "ใช้หรือไม่ใช้งาน gestures", + "@disableGestureSubtitle": {}, + "theme": "ธีม", + "@theme": {}, + "system": "ตามระบบ", + "@system": {}, + "light": "สว่าง", + "@light": {}, + "dark": "มืด", + "@dark": {}, + "tabs": "แท็บ", + "@tabs": {}, + "cancelSleepTimer": "ยกเลิกการตั้งเวลาปิด?", + "@cancelSleepTimer": {}, + "yesButtonLabel": "ใช่", + "@yesButtonLabel": {}, + "noButtonLabel": "ไม่ใช่", + "@noButtonLabel": {}, + "invalidNumber": "ตัวเลขไม่ถูกต้อง", + "@invalidNumber": {}, + "addToPlaylistTooltip": "เพิ่มไปยังเพลย์ลิสต์", + "@addToPlaylistTooltip": {}, + "removeFromPlaylistTooltip": "นำออกจากเพลย์ลิสต์", + "@removeFromPlaylistTooltip": {}, + "removeFromPlaylistTitle": "นำออกจากเพลย์ลิสต์", + "@removeFromPlaylistTitle": {}, + "createButtonLabel": "สร้าง", + "@createButtonLabel": {}, + "playlistCreated": "สร้างเพลย์ลิสต์แล้ว", + "@playlistCreated": {}, + "noAlbum": "ไม่มีอัลบั้ม", + "@noAlbum": {}, + "noItem": "ไม่มีรายการ", + "@noItem": {}, + "unknownArtist": "ศิลปินที่ไม่รู้จัก", + "@unknownArtist": {}, + "streaming": "กำลังสตรีม", + "@streaming": {}, + "downloaded": "ดาวน์โหลดแล้ว", + "@downloaded": {}, + "direct": "โดยตรง", + "@direct": {}, + "queue": "คิว", + "@queue": {}, + "addToQueue": "เพิ่มไปยังคิว", + "@addToQueue": {}, + "replaceQueue": "แทนที่คิว", + "@replaceQueue": {}, + "instantMix": "มิกซ์ทันที", + "@instantMix": {}, + "goToAlbum": "ไปยังอัลบั้ม", + "@goToAlbum": {}, + "addFavourite": "เพิ่มไปยังรายการโปรด", + "@addFavourite": {}, + "addedToQueue": "เพิ่มลงคิวแล้ว", + "@addedToQueue": {}, + "removedFromPlaylist": "นำออกจากเพลย์ลิสต์แล้ว", + "@removedFromPlaylist": {}, + "startingInstantMix": "กำลังเริ่มมิกซ์ทันที", + "@startingInstantMix": {}, + "anErrorHasOccured": "พบข้อผิดพลาด", + "@anErrorHasOccured": {}, + "removeFromMix": "นำออกจากมิกซ์", + "@removeFromMix": {}, + "settings": "ตั้งค่า", + "@settings": {}, + "addToMix": "เพิ่มไปยังมิกซ์", + "@addToMix": {}, + "redownloadedItems": "{count,plural, =0{ไม่จำเป็นต้องดาวน์โหลดใหม่} =1{ดาวน์โหลดอีกครั้ง {count} รายการ} other{ดาวน์โหลดอีกครั้ง{count} รายการ}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "bufferDuration": "ระยะเวลาที่บัฟเฟอร์", + "@bufferDuration": {}, + "language": "ภาษา", + "@language": {}, + "offlineMode": "โหมดออฟไลน์", + "@offlineMode": {}, + "album": "อัลบั้ม", + "@album": {}, + "startupError": "พบข้อผิดพลาดขณะเริ่มต้นแอป ข้อผิดพลาด: {error}\n\nโปรดสร้าง Issue บน github.com/UnicornsOnLSD/finamp พร้อมแนบภาพหน้าจอของหน้านี้ หากปัญหานี้ยังคงเป็นอยู่ให้ลองเคลียร์ข้อมูลของแอปเพื่อรีเซ็ต", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "selectDirectory": "เลือกโฟลเดอร์", + "@selectDirectory": {}, + "dateAdded": "วันที่เพิ่ม", + "@dateAdded": {}, + "playCount": "จำนวนครั้งที่เล่น", + "@playCount": {}, + "random": "สุ่ม", + "@random": {}, + "runtime": "เวลา", + "@runtime": {}, + "downloadedItemsCount": "{count,plural,=1{{count} รายการ} other{{count} รายการ}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrors": "ข้อผิดพลาดการดาวน์โหลด", + "@downloadErrors": {}, + "downloadedMissingImages": "{count,plural, =0{ไม่มีภาพที่ขาด} =1{ดาวน์โหลดภาพที่ขาดแล้ว {count} รูป} other{ดาวน์โหลดภาพที่ขาดแล้ว {count} รูป}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedImagesCount": "{count,plural,=1{{count} รูป} other{{count} รูป}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "updateButtonLabel": "อัปเดต", + "@updateButtonLabel": {}, + "bitrateSubtitle": "บิตเรตที่สูงขึ้นจะช่วยให้เสียงดีขึ้นแต่ก็แลกมาด้วยการใช้งานเน็ตเวิร์กที่มากขึ้น", + "@bitrateSubtitle": {}, + "songCount": "{count,plural,=1{{count} เพลง} other{{count} เพลง}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "กำลังทำงาน {count}", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "editPlaylistNameTooltip": "แก้ไขชื่อเพลย์ลิสต์", + "@editPlaylistNameTooltip": {}, + "downloadErrorsTitle": "ข้อผิดพลาดในการดาวน์โหลด", + "@downloadErrorsTitle": {}, + "discNumber": "แผ่นที่ {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "editPlaylistNameTitle": "แก้ไขชื่อเพลย์ลิสต์", + "@editPlaylistNameTitle": {}, + "errorScreenError": "พบข้อผิดพลาดในการดึงรายการข้อผิดพลาด! ถ้าถึงขั้นนี้แล้ว คุณน่าจะต้องไปสร้าง Issue บน Github แล้วลบข้อมูลแอปทิ้งซะ", + "@errorScreenError": {}, + "error": "ผิดพลาด", + "@error": {}, + "playButtonLabel": "เล่น", + "@playButtonLabel": {}, + "required": "จำเป็น", + "@required": {}, + "failedToGetSongFromDownloadId": "ล้มเหลวในการเรียกข้อมูลเพลงจากไอดีสำหรับดาวน์โหลด", + "@failedToGetSongFromDownloadId": {}, + "shuffleButtonLabel": "สุ่ม", + "@shuffleButtonLabel": {}, + "playlistNameUpdated": "อัปเดตชื่อเพลย์ลิสต์แล้ว", + "@playlistNameUpdated": {}, + "message": "ข้อความ", + "@message": {}, + "responseError401": "{error} รหัสข้อผิดพลาด {statusCode} เป็นไปได้ว่าคุณอาจจะใช้ ชื่อผู้ใช้หรือรหัสผ่านที่ไม่ถูกต้อง หรือตัวโปรแกรมไม่ได้อยู่ในสถานะล็อกอินแล้ว", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "applicationLegalese": "สงวนลิขสิทธิ์โดย Mozilla Public License 2.0 สามารถดาวน์โหลด Source Code ได้ที่:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "logOut": "ออกจากระบบ", + "@logOut": {}, + "areYouSure": "แน่ใจหรือไม่?", + "@areYouSure": {}, + "addDownloadLocation": "เพิ่มตำแหน่งสำหรับดาวน์โหลด", + "@addDownloadLocation": {}, + "downloadedSongsWillNotBeDeleted": "เพลงที่ดาวน์โหลดแล้วจะไม่ถูกลบ", + "@downloadedSongsWillNotBeDeleted": {}, + "bufferDurationSubtitle": "ตัวเล่นเพลงควรจะอ่านไฟล์ล่วงหน้าเท่าไร ระบุเป็นวินาที หากเปลี่ยนต้องรีสตาร์ท", + "@bufferDurationSubtitle": {}, + "unknownError": "ข้อผิดพลาดที่ไม่รู้จัก", + "@unknownError": {}, + "pathReturnSlashErrorMessage": "ตำแหน่งที่ได้ \"/\" ไม่สามารถใช้ได้", + "@pathReturnSlashErrorMessage": {}, + "enterLowPriorityStateOnPauseSubtitle": "ให้แถบแจ้งเตือนสามารถปัดทิ้งไปได้เมื่อหยุดชั่วคราว รวมถึงให้แอนดรอยสามารถปิดเซอร์วิสนี้ได้เมื่อหยุดชั่วคราว", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "customLocationsBuggy": "ตำแหน่งแบบเลือกเองนั้นค่อนข้างไม่สเถียรเนื่องจากปัญหาด้านสิทธิ์ ตอนนี้ยังหาวิธีแก้เรื่องนี้อยู่ ทางที่ดีตอนนี้ไม่ค่อยแนะนำให้ใช้ฟีเจอร์นี้", + "@customLocationsBuggy": {}, + "viewType": "ประเภทการแสดงผล", + "@viewType": {}, + "transcode": "แปลงไฟล์", + "@transcode": {}, + "internalExternalIpExplanation": "หากคุณต้องการเข้าถึงเซิร์ฟเวอร์ Jellyfin จากภายนอก คุณจำเป็นต้องมี IP ภายนอก\n\nหากเซิร์ฟเวอร์ของคุณรันบนพอร์ต HTTP (80/443) คุณไม่จำเป็นต้องระบุพอร์ต ในเคสนี้เซิร์ฟเวอร์ของคุณอาจจะอยู่หลัง Reverse Proxy", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "emptyServerUrl": "จำเป็นต้องระบุ URL ของเซิร์ฟเวอร์", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "urlStartWithHttps": "URL ต้องขึ้นต้นด้วย http:// หรือ https://", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "urlTrailingSlash": "URL ต้องไม่มีเครื่องหมาย / ต่อหลัง", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "username": "ชื่อผู้ใช้", + "@username": {}, + "password": "รหัสผ่าน", + "@password": {}, + "playlists": "เพลย์ลิสต์", + "@playlists": {}, + "startMixNoSongsArtist": "แตะค้างที่ศิลปินเพื่อเพิ่มหรือนำออกจากตัวสร้างมิกซ์ก่อนที่จะเริ่มมิกซ์", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "couldNotFindLibraries": "ไม่พบคลังใดๆ", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "albums": "อัลบั้ม", + "@albums": {}, + "downloads": "ดาวน์โหลด", + "@downloads": {}, + "sortOrder": "เรียงลำดับ", + "@sortOrder": {}, + "sortBy": "เรียงด้วย", + "@sortBy": {}, + "unknownName": "ไม่รู้จักชื่อ", + "@unknownName": {}, + "finamp": "ฟินแอมป์", + "@finamp": {}, + "songs": "เพลง", + "@songs": {}, + "startMix": "เริ่มมิกซ์", + "@startMix": {}, + "shuffleAll": "สุ่มทั้งหมด", + "@shuffleAll": {}, + "showTextOnGridViewSubtitle": "แสดงหรือไม่แสดงข้อความ (ชื่อเพลง, ศิลปิน, อื่น ๆ) บนตารางแสดงเพลง", + "@showTextOnGridViewSubtitle": {}, + "resetTabs": "รีเซ็ตการตั้งค่าแท็ป", + "@resetTabs": {}, + "confirm": "ยืนยัน", + "@confirm": {}, + "showUncensoredLogMessage": "ล็อกนี้มีข้อมูลการล็อกอินของคุณอยู่ ต้องการให้แสดงใช่ไหม?", + "@showUncensoredLogMessage": {}, + "insertedIntoQueue": "แทรกไปยังคิว", + "@insertedIntoQueue": { + "description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end." + }, + "playNext": "เล่นถัดไป", + "@playNext": { + "description": "Popup menu item title for inserting an item into the play queue after the currently-playing item." + }, + "refresh": "รีเฟชร", + "@refresh": {}, + "noMusicLibrariesBody": "ฟินแอมป์ไม่พบคลังเพลงใดเลย โปรดตรวจสอบว่าเซิร์ฟเวอร์ของ Jellyfin นั้นมีอย่างน้อยหนึ่งคลังที่ตั้งประเภทของคอนเทนต์ไว้เป็น \"Music\"", + "@noMusicLibrariesBody": {}, + "noMusicLibrariesTitle": "ไม่มีคลังเพลง", + "@noMusicLibrariesTitle": { + "description": "Title for message that shows on the views screen when no music libraries could be found." + }, + "deleteDownloadsConfirmButtonText": "ลบ", + "@deleteDownloadsConfirmButtonText": { + "description": "Shown in the confirmation dialog for deleting downloaded media from the local device." + }, + "deleteDownloadsPrompt": "แน่ใจหรือไม่ว่าต้องการลบ {itemType, select, album{album} playlist{playlist} artist{artist} genre{genre} track{song} other{}} '{itemName}' จากเครื่องนี้?", + "@deleteDownloadsPrompt": { + "placeholders": { + "itemName": { + "type": "String", + "example": "Abandon Ship" + }, + "itemType": { + "type": "String", + "example": "album" + } + }, + "description": "Confirmation prompt shown before deleting downloaded media from the local device, destructive action, doesn't affect the media on the server." + }, + "deleteDownloadsAbortButtonText": "ยกเลิก", + "@deleteDownloadsAbortButtonText": {}, + "showFastScroller": "แสดงตัวเลื่อนแบบเร็ว", + "@showFastScroller": {}, + "syncDownloadedPlaylists": "ซิงก์เพลลิสต์ที่ดาวน์โหลดแล้ว", + "@syncDownloadedPlaylists": {}, + "interactions": "การกระทำ", + "@interactions": {} +} diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb new file mode 100644 index 0000000..e61c3a5 --- /dev/null +++ b/lib/l10n/app_tr.arb @@ -0,0 +1,539 @@ +{ + "startupError": "Uygulama başlarken bir hata meydana geldi. Hata: {error}\n\nLütfen bu sayfanın ekran görüntüsüyle github.com/UnicornsOnLSD/finamp adresinde bir issue oluşturun. Eğer bu sorun devam ederse uygulamayı sıfırlamak için uygulama verisini sıfırlayabilirsiniz.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "serverUrl": "Sunucunun URL'i", + "@serverUrl": {}, + "internalExternalIpExplanation": "Jellyfin sunucunuza uzaktan erişmek istiyorsanız dış IP adresinizi kullanmalısınız.\n\nEğer sunucunuz HTTP portlarından (80/443) birindeyse port belirtmenize gerek yok. Sunucunuz bir reverse proxy'nin arkasındaysa muhtemelen bu durum geçerlidir.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "emptyServerUrl": "Sunucu URL'i boş bırakılamaz", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "urlStartWithHttps": "URL http:// veya https:// ile başlamalı", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "urlTrailingSlash": "URL'in sonunda eğik çizgi olmamalı", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "username": "Kullanıcı adı", + "@username": {}, + "password": "Parola", + "@password": {}, + "logs": "Uygulama dökümleri", + "@logs": {}, + "next": "Sıradaki", + "@next": {}, + "unknownName": "Bilinmeyen İsim", + "@unknownName": {}, + "songs": "Şarkılar", + "@songs": {}, + "albums": "Albümler", + "@albums": {}, + "artists": "Sanatçılar", + "@artists": {}, + "genres": "Tarzlar", + "@genres": {}, + "playlists": "Çalma listeleri", + "@playlists": {}, + "startMix": "Mix'i başlat", + "@startMix": {}, + "startMixNoSongsArtist": "Mix'i başlatmadan önce bir sanatçıya uzun basarak mix'e ekleyip çıkar", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "startMixNoSongsAlbum": "Mix'i başlatmadan önce bir albüme uzun basarak mix'e ekleyip çıkar", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "music": "Müzik", + "@music": {}, + "clear": "Temizle", + "@clear": {}, + "favourites": "Favoriler", + "@favourites": {}, + "downloads": "İndirilenler", + "@downloads": {}, + "sortOrder": "Sıralama düzeni", + "@sortOrder": {}, + "sortBy": "Sıralama ölçütü", + "@sortBy": {}, + "artist": "Sanatçı", + "@artist": {}, + "budget": "Bütçe", + "@budget": {}, + "communityRating": "Topluluk Puanı", + "@communityRating": {}, + "criticRating": "Eleştirmen Puanı", + "@criticRating": {}, + "dateAdded": "Eklenme Tarihi", + "@dateAdded": {}, + "datePlayed": "Oynatma Tarihi", + "@datePlayed": {}, + "downloadedMissingImages": "{count,plural, =0{Eksik görsel yok} =1{{count} tane eksik görsel indirildi} other{{count} tane eksik görsel indirildi}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrors": "İndirme hataları", + "@downloadErrors": {}, + "downloadedItemsCount": "{count,plural,=1{{count} dosya} other{{count} dosya}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedImagesCount": "{count,plural,=1{{count} tane görsel} other{{count} tane görsel}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "downloadErrorsTitle": "İndirme Hataları", + "@downloadErrorsTitle": {}, + "noErrors": "Hata yok!", + "@noErrors": {}, + "errorScreenError": "Hata listesi oluşturulurken hata meydana geldi. Bu noktada, GitHub'da bir sorun oluşturmalı ve uygulama verilerini silmelisiniz", + "@errorScreenError": {}, + "failedToGetSongFromDownloadId": "İndirme ID'sinden şarkıya ulaşılamadı", + "@failedToGetSongFromDownloadId": {}, + "error": "Hata", + "@error": {}, + "downloadCount": "{count,plural, =1{{count} tane indirme} other{{count} tane indirme{count}}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "discNumber": "Disk {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "playButtonLabel": "OYNAT", + "@playButtonLabel": {}, + "shuffleButtonLabel": "KARIŞTIR", + "@shuffleButtonLabel": {}, + "songCount": "{count,plural,=1{{count} Şarkı} other{{count} Şarkı}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "editPlaylistNameTooltip": "Çalma listesinin adını düzenle", + "@editPlaylistNameTooltip": {}, + "editPlaylistNameTitle": "Çalma Listesinin Adını Düzenle", + "@editPlaylistNameTitle": {}, + "required": "Gerekli", + "@required": {}, + "updateButtonLabel": "GÜNCELLE", + "@updateButtonLabel": {}, + "playlistNameUpdated": "Çalma listesinin adı güncellendi.", + "@playlistNameUpdated": {}, + "favourite": "Favori", + "@favourite": {}, + "downloadsDeleted": "İndirmeler silindi.", + "@downloadsDeleted": {}, + "addDownloads": "İndirme Ekle", + "@addDownloads": {}, + "location": "Lokasyon", + "@location": {}, + "downloadsAdded": "İndirilenler eklendi.", + "@downloadsAdded": {}, + "addButtonLabel": "EKLE", + "@addButtonLabel": {}, + "shareLogs": "Uygulama dökümlerini paylaş", + "@shareLogs": {}, + "logsCopied": "Uygulama dökümleri kopyalandı.", + "@logsCopied": {}, + "stackTrace": "Fonksiyon Çağrı Yığını", + "@stackTrace": {}, + "downloadLocations": "İndirilenler Lokasyonları", + "@downloadLocations": {}, + "transcoding": "Yeniden kodlama (transcoding)", + "@transcoding": {}, + "notAvailableInOfflineMode": "Çevrim dışı modda mevcut değil", + "@notAvailableInOfflineMode": {}, + "logOut": "Çıkış Yap", + "@logOut": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin transcoding için AAC kullanıyor", + "@jellyfinUsesAACForTranscoding": {}, + "enableTranscoding": "Transcoding'i aktifleştir", + "@enableTranscoding": {}, + "enableTranscodingSubtitle": "Sunucu tarafında müzik akışlarını yeniden kodlar.", + "@enableTranscodingSubtitle": {}, + "bitrate": "Bit oranı", + "@bitrate": {}, + "bitrateSubtitle": "Daha yüksek bir bit oranı, daha fazla bant genişliği kullanır ancak daha kaliteli ses verir.", + "@bitrateSubtitle": {}, + "customLocation": "Farklı Lokasyon", + "@customLocation": {}, + "appDirectory": "Uygulama Klasörü", + "@appDirectory": {}, + "addDownloadLocation": "İndirme Lokasyonu Ekle", + "@addDownloadLocation": {}, + "selectDirectory": "Klasör Seç", + "@selectDirectory": {}, + "unknownError": "Bilinmeyen Hata", + "@unknownError": {}, + "pathReturnSlashErrorMessage": "\"/\" döndüren yollar kullanılamaz", + "@pathReturnSlashErrorMessage": {}, + "directoryMustBeEmpty": "Klasör boş olmalı", + "@directoryMustBeEmpty": {}, + "customLocationsBuggy": "İzinlerden kaynaklanan sorunlar dolayısıyla özel konum seçmek fazlasıyla bug'a yol açmakta. Şimdilik kullanmanızı önermiyorum, çözmenin bir yolunu düşünüyorum.", + "@customLocationsBuggy": {}, + "enterLowPriorityStateOnPauseSubtitle": "Şarkı duraklatıldığında bildirimin temizlenebilmesini sağlar. Ayrıca Android'in hizmeti kapatmasına izin verir.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "shuffleAllSongCount": "Karıştırılacak Tüm Şarkıların Sayısı", + "@shuffleAllSongCount": {}, + "viewTypeSubtitle": "Müzik ekranı için görüntüleme tipi", + "@viewTypeSubtitle": {}, + "list": "Liste", + "@list": {}, + "grid": "Izgara", + "@grid": {}, + "portrait": "Dikey mod", + "@portrait": {}, + "landscape": "Yatay mod", + "@landscape": {}, + "gridCrossAxisCountSubtitle": "Değer {value} olduğunda satır başına kullanılacak ızgara karosu.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "showTextOnGridView": "Izgara görünümünde metin göster", + "@showTextOnGridView": {}, + "showCoverAsPlayerBackground": "Oynatıcı arkaplanı olarak bulanık kapak fotoğrafını göster", + "@showCoverAsPlayerBackground": {}, + "hideSongArtistsIfSameAsAlbumArtists": "Albüm ile şarkı sanatçısı aynıysa gösterme", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Eğer albüm sanatçısından farklı değilse şarkı sanatçısını albüm ekranında gösterilip gösterilmeyeceğini belirler.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "disableGesture": "Jestleri devre dışı bırak", + "@disableGesture": {}, + "disableGestureSubtitle": "Jestleri devre dışı bırakır veya aktifleştirir.", + "@disableGestureSubtitle": {}, + "theme": "Tema", + "@theme": {}, + "gridCrossAxisCount": "{value} Izgara Çapraz Eksen Sayısı", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "system": "Sistem", + "@system": {}, + "light": "Aydınlık", + "@light": {}, + "dark": "Karanlık", + "@dark": {}, + "tabs": "Sekmeler", + "@tabs": {}, + "cancelSleepTimer": "Uyuma Zamanlayıcısını İptal Et?", + "@cancelSleepTimer": {}, + "yesButtonLabel": "EVET", + "@yesButtonLabel": {}, + "noButtonLabel": "HAYIR", + "@noButtonLabel": {}, + "setSleepTimer": "Uyuma Zamanlayıcısını Ayarla", + "@setSleepTimer": {}, + "minutes": "Dakika", + "@minutes": {}, + "invalidNumber": "Geçersiz Sayı", + "@invalidNumber": {}, + "sleepTimerTooltip": "Uyku zamanlayıcısı", + "@sleepTimerTooltip": {}, + "addToPlaylistTooltip": "Çalma listesine ekle", + "@addToPlaylistTooltip": {}, + "addToPlaylistTitle": "Listeye Ekle", + "@addToPlaylistTitle": {}, + "removeFromPlaylistTooltip": "Çalma listesinden çıkart", + "@removeFromPlaylistTooltip": {}, + "removeFromPlaylistTitle": "Listeden Çıkart", + "@removeFromPlaylistTitle": {}, + "newPlaylist": "Yeni Çalma Listesi", + "@newPlaylist": {}, + "createButtonLabel": "OLUŞTUR", + "@createButtonLabel": {}, + "playlistCreated": "Çalma listesi oluşturuldu.", + "@playlistCreated": {}, + "noAlbum": "Albüm Yok", + "@noAlbum": {}, + "noArtist": "Sanatçı Yok", + "@noArtist": {}, + "noItem": "İçerik Yok", + "@noItem": {}, + "unknownArtist": "Bilinmeyen Sanatçı", + "@unknownArtist": {}, + "streaming": "YAYIN ALINIYOR", + "@streaming": {}, + "downloaded": "İNDİRİLDİ", + "@downloaded": {}, + "transcode": "YENİDEN KODLA", + "@transcode": {}, + "direct": "DİREKT", + "@direct": {}, + "statusError": "DURUM HATASI", + "@statusError": {}, + "queue": "Kuyruk", + "@queue": {}, + "replaceQueue": "Kuyruğu Değiştir", + "@replaceQueue": {}, + "addedToQueue": "Kuyruğa eklendi.", + "@addedToQueue": {}, + "queueReplaced": "Kuyruk değiştirildi.", + "@queueReplaced": {}, + "removedFromPlaylist": "Çalma listesinden çıkarıldı.", + "@removedFromPlaylist": {}, + "startingInstantMix": "Anlık mix başlatılıyor.", + "@startingInstantMix": {}, + "anErrorHasOccured": "Bir hata meydana geldi.", + "@anErrorHasOccured": {}, + "responseError": "{error} Durum kodu {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "removeFromMix": "Mix'ten Çıkart", + "@removeFromMix": {}, + "addToMix": "Mix'e Ekle", + "@addToMix": {}, + "redownloadedItems": "{count,plural, =0{Yeniden indirmeye gerek yok.} =1{{count} tane yeniden indirildi.} other{{count} tane yeniden indirildi.}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "bufferDuration": "Önden Kaydetme Süresi", + "@bufferDuration": {}, + "bufferDurationSubtitle": "Oynatıcının kaç saniye önden kaydetmesi gerektiğini ayarlar. Yeniden başlatmayı gerektirir.", + "@bufferDurationSubtitle": {}, + "album": "Albüm", + "@album": {}, + "selectMusicLibraries": "Müzik Kütüphanelerini Seç", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "couldNotFindLibraries": "Hiçbir kütüphane bulunamadı.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "finamp": "Finamp", + "@finamp": {}, + "shuffleAll": "Tümünü karıştır", + "@shuffleAll": {}, + "playCount": "Oynatma Sayısı", + "@playCount": {}, + "name": "İsim", + "@name": {}, + "offlineMode": "Çevrim dışı Mod", + "@offlineMode": {}, + "settings": "Ayarlar", + "@settings": {}, + "albumArtist": "Albüm Sanatçısı", + "@albumArtist": {}, + "productionYear": "Prodüksiyon Yılı", + "@productionYear": {}, + "random": "Rastgele", + "@random": {}, + "runtime": "Çalma süresi", + "@runtime": {}, + "dlFailed": "{count} tane başarısız", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "premiereDate": "Gösterim Tarihi", + "@premiereDate": {}, + "revenue": "Gelir", + "@revenue": {}, + "downloadMissingImages": "Eksik görselleri indir", + "@downloadMissingImages": {}, + "dlRunning": "{count} tane devam ediyor", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlEnqueued": "{count} tane kuyruğa alındı", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlComplete": "{count} tane tamamlandı", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "message": "Mesaj", + "@message": {}, + "areYouSure": "Emin misiniz?", + "@areYouSure": {}, + "downloadedSongsWillNotBeDeleted": "İndirilmiş şarkılar silinmeyecek", + "@downloadedSongsWillNotBeDeleted": {}, + "applicationLegalese": "Mozilla Public License 2.0 ile lisanslandı. Kaynak koda buradan ulaşılabilir:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "audioService": "Ses Servisi", + "@audioService": {}, + "layoutAndTheme": "Düzen & Tema", + "@layoutAndTheme": {}, + "enterLowPriorityStateOnPause": "Bekletmede Düşük Öncelik Durumuna Geç", + "@enterLowPriorityStateOnPause": {}, + "shuffleAllSongCountSubtitle": "Tüm şarkıları karıştır butonuna tıklandığında karıştırılacak şarkıların sayısı.", + "@shuffleAllSongCountSubtitle": {}, + "viewType": "Görüntüleme Tipi", + "@viewType": {}, + "showCoverAsPlayerBackgroundSubtitle": "Oynatıcı arkaplanı olarak bulanık kapak fotoğrafını gösterilip gösterilmeyeceğini belirler.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "showTextOnGridViewSubtitle": "Izgara müzik ekranında metin (başlık, sanatçı, vs.) gösterilip gösterilmeyeceğini belirler.", + "@showTextOnGridViewSubtitle": {}, + "addToQueue": "Kuyruğa Ekle", + "@addToQueue": {}, + "instantMix": "Anlık Mix", + "@instantMix": {}, + "goToAlbum": "Albüme Git", + "@goToAlbum": {}, + "addFavourite": "Favoriye Ekle", + "@addFavourite": {}, + "removeFavourite": "Favoriden Çıkart", + "@removeFavourite": {}, + "responseError401": "{error} Durum kodu {statusCode}. Muhtemelen yanlış kullanıcı adı veya şifreyi kullandınız ya da uygulamadan çıkış yaptınız.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "language": "Dil", + "@language": {}, + "confirm": "Onayla", + "@confirm": {}, + "showUncensoredLogMessage": "Bu günlük oturum açma bilgilerinizi içerir. Gösterilsin mi?", + "@showUncensoredLogMessage": {}, + "resetTabs": "Sekmeleri sıfırla", + "@resetTabs": {}, + "playNext": "Ardından Oynat", + "@playNext": { + "description": "Popup menu item title for inserting an item into the play queue after the currently-playing item." + }, + "insertedIntoQueue": "Kuyruğun içine eklendi.", + "@insertedIntoQueue": { + "description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end." + }, + "refresh": "YENİLE", + "@refresh": {}, + "noMusicLibrariesBody": "Finamp herhangi bir müzik kütüphanesi bulamadı. Lütfen Jellyfin sunucunun içerik türü \"Müzik\" olarak ayarlanmış en az bir kütüphaneye sahip olduğundan emin ol.", + "@noMusicLibrariesBody": {}, + "noMusicLibrariesTitle": "Müzik Kütüphanesi Bulunamadı", + "@noMusicLibrariesTitle": { + "description": "Title for message that shows on the views screen when no music libraries could be found." + }, + "deleteDownloadsConfirmButtonText": "Sil", + "@deleteDownloadsConfirmButtonText": { + "description": "Shown in the confirmation dialog for deleting downloaded media from the local device." + }, + "deleteDownloadsPrompt": "Bu aygıttan {itemType, select, album{album} playlist{playlist} artist{artist} genre{genre} track{song} other{}} '{itemName}' silmek istediğinizden emin misiniz?", + "@deleteDownloadsPrompt": { + "placeholders": { + "itemName": { + "type": "String", + "example": "Abandon Ship" + }, + "itemType": { + "type": "String", + "example": "album" + } + }, + "description": "Confirmation prompt shown before deleting downloaded media from the local device, destructive action, doesn't affect the media on the server." + }, + "deleteDownloadsAbortButtonText": "İptal", + "@deleteDownloadsAbortButtonText": {}, + "syncDownloadedPlaylists": "İndirilen çalma listelerini eşzamanla", + "@syncDownloadedPlaylists": {}, + "showFastScroller": "Hızlı kaydırıcıyı göster", + "@showFastScroller": {}, + "interactions": "Etkileşimler", + "@interactions": {}, + "swipeInsertQueueNext": "Kaydırılan Şarkıyı Ardından Oynat", + "@swipeInsertQueueNext": {}, + "swipeInsertQueueNextSubtitle": "Şarkı listesinde kaydırıldığında bir şarkıyı sona eklemek yerine sıradaki öge olarak eklemeyi etkinleştirin.", + "@swipeInsertQueueNextSubtitle": {}, + "redesignBeta": "Yeniden Tasarım Beta", + "@redesignBeta": {} +} diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb new file mode 100644 index 0000000..066290d --- /dev/null +++ b/lib/l10n/app_uk.arb @@ -0,0 +1,531 @@ +{ + "audioService": "Служба аудіовідтворення", + "@audioService": {}, + "layoutAndTheme": "Вигляд і тема", + "@layoutAndTheme": {}, + "notAvailableInOfflineMode": "Недоступне в оффлайн режимі", + "@notAvailableInOfflineMode": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin використовує ААС для транскодування", + "@jellyfinUsesAACForTranscoding": {}, + "enableTranscodingSubtitle": "Перекодування музичного потоку відбувається зі сторони сервера.", + "@enableTranscodingSubtitle": {}, + "customLocation": "Власна папка", + "@customLocation": {}, + "pathReturnSlashErrorMessage": "Шляхи, які повертають \"/\", не можна використовувати", + "@pathReturnSlashErrorMessage": {}, + "enterLowPriorityStateOnPause": "Перейти у стан низького пріоритету під час паузи", + "@enterLowPriorityStateOnPause": {}, + "shuffleAllSongCount": "Перемішати всі пісні", + "@shuffleAllSongCount": {}, + "shuffleAllSongCountSubtitle": "Кількість пісень для завантаження при використанні кнопки перемішування всіх пісень.", + "@shuffleAllSongCountSubtitle": {}, + "viewTypeSubtitle": "Тип перегляду для музичного екрану", + "@viewTypeSubtitle": {}, + "list": "Список", + "@list": {}, + "landscape": "Альбомний", + "@landscape": {}, + "gridCrossAxisCount": "{value} Підрахунок поперечних осей сітки", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "showTextOnGridView": "Показати текст при перегляді сіткою", + "@showTextOnGridView": {}, + "showCoverAsPlayerBackgroundSubtitle": "Використовувати або ні розмиту обкладинку як фон на екрані програвача.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "disableGestureSubtitle": "Чи вимикати жести.", + "@disableGestureSubtitle": {}, + "theme": "Тема", + "@theme": {}, + "system": "Системна", + "@system": {}, + "tabs": "Закладки", + "@tabs": {}, + "cancelSleepTimer": "Скасувати таймер сну?", + "@cancelSleepTimer": {}, + "yesButtonLabel": "ТАК", + "@yesButtonLabel": {}, + "noButtonLabel": "НІ", + "@noButtonLabel": {}, + "minutes": "Хвилини", + "@minutes": {}, + "addToPlaylistTitle": "Додати до плейлисту", + "@addToPlaylistTitle": {}, + "removeFromPlaylistTooltip": "Видалити з плейлисту", + "@removeFromPlaylistTooltip": {}, + "createButtonLabel": "СТВОРИТИ", + "@createButtonLabel": {}, + "playlistCreated": "Плейлист створено.", + "@playlistCreated": {}, + "noAlbum": "Альбом невідомий", + "@noAlbum": {}, + "noItem": "Невідомий елемент", + "@noItem": {}, + "noArtist": "Невідомий виконавець", + "@noArtist": {}, + "unknownArtist": "Невідомий виконавець", + "@unknownArtist": {}, + "downloaded": "ЗАВАНТАЖЕНО", + "@downloaded": {}, + "direct": "ПРЯМЕ ВІДТВОРЕННЯ", + "@direct": {}, + "streaming": "ВІДТВОРЮЄТЬСЯ ЗАРАЗ", + "@streaming": {}, + "statusError": "ПОМИЛКОВИЙ СТАТУС", + "@statusError": {}, + "queue": "Черга", + "@queue": {}, + "replaceQueue": "Замінити чергу", + "@replaceQueue": {}, + "instantMix": "Швидкий мікс", + "@instantMix": {}, + "goToAlbum": "До альбому", + "@goToAlbum": {}, + "removeFavourite": "Видалити з вибраного", + "@removeFavourite": {}, + "addedToQueue": "Додано в чергу.", + "@addedToQueue": {}, + "queueReplaced": "Черга змінена.", + "@queueReplaced": {}, + "startingInstantMix": "Починається швидкий мікс.", + "@startingInstantMix": {}, + "anErrorHasOccured": "Сталася помилка.", + "@anErrorHasOccured": {}, + "responseError": "{error} Код статусу {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "removeFromMix": "Видалити з міксу", + "@removeFromMix": {}, + "addToMix": "Додати до міксу", + "@addToMix": {}, + "bufferDurationSubtitle": "Скільки плеєр має буферизувати, у секундах. Вимагає перезавантаження.", + "@bufferDurationSubtitle": {}, + "language": "Мова", + "@language": {}, + "startupError": "Щось пішло не так під час запуску програми. помилка: {error}\n\nБудь ласка, створіть проблему на github.com/UnicornsOnLSD/finamp із знімком екрана цієї сторінки. Якщо проблема не зникне, ви можете очистити дані програми, щоб скинути налаштування програми.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "serverUrl": "URL серверу", + "@serverUrl": {}, + "couldNotFindLibraries": "Неможливо знайти будь-яку музичну бібліотеку.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "playlists": "Списки відтворення", + "@playlists": {}, + "album": "Альбомами", + "@album": {}, + "songCount": "{count,plural,=1{{count} Пісня} other{{count} Пісні}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dateAdded": "Датою додавання", + "@dateAdded": {}, + "startMixNoSongsArtist": "Затисніть виконавця, щоб додати або видалити його з конструктора міксів перед початком міксування", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "internalExternalIpExplanation": "Якщо ви хочете мати віддалений доступ до свого сервера Jellyfin, вам потрібно використовувати зовнішню IP-адресу.\n\nЯкщо ваш сервер використовує порт HTTP (80/443), вам не потрібно вказувати порт. Ймовірно, це буде необхідно, якщо ваш сервер знаходиться за зворотним проксі.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "startMix": "Почати перемішування", + "@startMix": {}, + "artist": "Виконавцями", + "@artist": {}, + "startMixNoSongsAlbum": "Затисніть альбом, щоб додати або видалити його з конструктора міксів перед початком міксування", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "sortBy": "Сортування за", + "@sortBy": {}, + "albumArtist": "Виконавцями альбомів", + "@albumArtist": {}, + "communityRating": "Оцінкою спільноти", + "@communityRating": {}, + "playCount": "Кількістю відтворень", + "@playCount": {}, + "premiereDate": "Датою прем'єри", + "@premiereDate": {}, + "downloadErrors": "Завантажити помилки", + "@downloadErrors": {}, + "downloadCount": "{count,plural, =1{{count} Завантажено} other{{count} Завантажень}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "datePlayed": "Датою відтворення", + "@datePlayed": {}, + "name": "Ім'ям", + "@name": {}, + "downloadedMissingImages": "{count,plural, =0{Відсутніх зображень не знайдено} =1{Завантажено {count} відсутніх зображень} other{Завантажено {count} відсутніх зображень}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlComplete": "{count} виконано", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "errorScreenError": "Під час отримання списку помилок сталася помилка! На цьому етапі вам, ймовірно, слід просто створити проблему на GitHub і видалити дані програми", + "@errorScreenError": {}, + "error": "Помилка", + "@error": {}, + "dlFailed": "{count} не вдалося", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "required": "Обов'язково", + "@required": {}, + "dlEnqueued": "{count} поставлено в чергу", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "failedToGetSongFromDownloadId": "Не вдалося отримати пісню з ідентифікатора завантаження", + "@failedToGetSongFromDownloadId": {}, + "discNumber": "Платівка {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "editPlaylistNameTooltip": "Редагувати назву плейлисту", + "@editPlaylistNameTooltip": {}, + "updateButtonLabel": "ОНОВИТИ", + "@updateButtonLabel": {}, + "dlRunning": "{count} запущено", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "Завантажити помилки", + "@downloadErrorsTitle": {}, + "editPlaylistNameTitle": "Редагувати назву плейлисту", + "@editPlaylistNameTitle": {}, + "transcoding": "Транскодування", + "@transcoding": {}, + "logsCopied": "Логи скопійовано.", + "@logsCopied": {}, + "downloadedSongsWillNotBeDeleted": "Завантажені пісні не будуть видалені", + "@downloadedSongsWillNotBeDeleted": {}, + "shareLogs": "Поділитися логами", + "@shareLogs": {}, + "downloadLocations": "Місце завантаження", + "@downloadLocations": {}, + "addButtonLabel": "ДОДАТИ", + "@addButtonLabel": {}, + "applicationLegalese": "Ліцензовано за Mozilla Public License 2.0. Вихідний код доступний за адресою:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "bitrate": "Бітрейт", + "@bitrate": {}, + "logOut": "Вийти", + "@logOut": {}, + "enableTranscoding": "Ввімкнути транскодування", + "@enableTranscoding": {}, + "areYouSure": "Ви впевнені ?", + "@areYouSure": {}, + "dark": "Темна", + "@dark": {}, + "appDirectory": "Тека застосунку", + "@appDirectory": {}, + "bitrateSubtitle": "Вищий бітрейт забезпечує вищу якість звуку за рахунок вищої пропускної здатності.", + "@bitrateSubtitle": {}, + "addDownloadLocation": "Додати теку завантаження", + "@addDownloadLocation": {}, + "unknownError": "Невідома помилка", + "@unknownError": {}, + "selectDirectory": "Вибрати теку", + "@selectDirectory": {}, + "directoryMustBeEmpty": "Тека повинна бути порожньою", + "@directoryMustBeEmpty": {}, + "enterLowPriorityStateOnPauseSubtitle": "Дозволяє змахнути сповіщення під час паузи. Також дозволяє Android вимикати службу під час паузи.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "viewType": "Тип перегляду", + "@viewType": {}, + "gridCrossAxisCountSubtitle": "Кількість плиток сітки для використання в рядку, коли {value}.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "customLocationsBuggy": "Користувацька текс має багато проблем через проблеми з дозволами. Я думаю, як це виправити, але поки що я б не рекомендував ними користуватися.", + "@customLocationsBuggy": {}, + "grid": "Таблиця", + "@grid": {}, + "portrait": "Портретний", + "@portrait": {}, + "showTextOnGridViewSubtitle": "Відображати чи ні текст (назву, виконавця тощо) на музичному екрані сітки.", + "@showTextOnGridViewSubtitle": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Чи показувати виконавців пісень на екрані альбому, якщо вони не відрізняються від виконавців альбому.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "disableGesture": "Вимкнути жести", + "@disableGesture": {}, + "showCoverAsPlayerBackground": "Показати розмиту обкладинку як фон гравця", + "@showCoverAsPlayerBackground": {}, + "light": "Світла", + "@light": {}, + "hideSongArtistsIfSameAsAlbumArtists": "Приховати виконавців пісень, якщо вони збігаються з виконавцями альбомів", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "setSleepTimer": "Встановити таймер сну", + "@setSleepTimer": {}, + "sleepTimerTooltip": "Таймер сну", + "@sleepTimerTooltip": {}, + "invalidNumber": "Некоректне число", + "@invalidNumber": {}, + "addToPlaylistTooltip": "Додати до плейлисту", + "@addToPlaylistTooltip": {}, + "transcode": "ПЕРЕКОДОВУЄТЬСЯ", + "@transcode": {}, + "removeFromPlaylistTitle": "Видалити з плейлисту", + "@removeFromPlaylistTitle": {}, + "newPlaylist": "Новий плейлист", + "@newPlaylist": {}, + "addToQueue": "Додати до черги", + "@addToQueue": {}, + "addFavourite": "Додати в вибране", + "@addFavourite": {}, + "responseError401": "{error} Код статусу {statusCode}. Можливо, це означає, що ви використали неправильне ім’я користувача/пароль або ваш клієнт не ввійшов в систему.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "removedFromPlaylist": "Видалено з плейлисту.", + "@removedFromPlaylist": {}, + "redownloadedItems": "{count,plural, =0{Повторне завантаження не потрібне.} =1{Повторно завантажено {count} елемент} other{Повторно завантажено {count} елементів}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "bufferDuration": "Тривалість буферу", + "@bufferDuration": {}, + "downloadedItemsCount": "{count,plural,=1{{count} елементів} other{{count} елементів}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedImagesCount": "{count,plural,=1{{count} Зображення} other{{count} зображень}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "offlineMode": "Оффлайн режим", + "@offlineMode": {}, + "sortOrder": "Порядок сортування", + "@sortOrder": {}, + "emptyServerUrl": "URL серверу не може бути порожнім", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "urlStartWithHttps": "URL має починатися з http:// або http://", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "urlTrailingSlash": "URL-адреса не повинна містити косу риску в кінці", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "username": "Логін", + "@username": {}, + "password": "Пароль", + "@password": {}, + "logs": "Логи", + "@logs": {}, + "next": "Далі", + "@next": {}, + "selectMusicLibraries": "Виберіть музичну бібліотеку", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "unknownName": "Невідома назва", + "@unknownName": {}, + "songs": "Пісні", + "@songs": {}, + "albums": "Альбоми", + "@albums": {}, + "artists": "Виконавці", + "@artists": {}, + "genres": "Жанри", + "@genres": {}, + "music": "Музика", + "@music": {}, + "clear": "Очистити", + "@clear": {}, + "favourites": "Вибране", + "@favourites": {}, + "shuffleAll": "Перемішати все", + "@shuffleAll": {}, + "finamp": "Finamp", + "@finamp": {}, + "downloads": "Завантаження", + "@downloads": {}, + "settings": "Налаштування", + "@settings": {}, + "budget": "Бюджет", + "@budget": {}, + "criticRating": "Оцінкою критиків", + "@criticRating": {}, + "productionYear": "Роком виходу", + "@productionYear": {}, + "random": "Випадково", + "@random": {}, + "revenue": "Дохід", + "@revenue": {}, + "runtime": "Тривалістю", + "@runtime": {}, + "downloadMissingImages": "Завантажити відсутні зображення", + "@downloadMissingImages": {}, + "noErrors": "Немає помилок!", + "@noErrors": {}, + "playButtonLabel": "ГРАТИ", + "@playButtonLabel": {}, + "shuffleButtonLabel": "ПЕРЕМІШАТИ", + "@shuffleButtonLabel": {}, + "playlistNameUpdated": "Ім'я плейлисту змінено.", + "@playlistNameUpdated": {}, + "favourite": "Улюблене", + "@favourite": {}, + "downloadsDeleted": "Завантаження видалено.", + "@downloadsDeleted": {}, + "addDownloads": "Додати завантаження", + "@addDownloads": {}, + "location": "Збережено в", + "@location": {}, + "downloadsAdded": "Завантаження додано.", + "@downloadsAdded": {}, + "message": "Повідомлення", + "@message": {}, + "stackTrace": "Трасування стека", + "@stackTrace": {}, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "confirm": "Підтвердити", + "@confirm": {}, + "showUncensoredLogMessage": "Цей журнал містить ваші дані для входу. Показати?", + "@showUncensoredLogMessage": {}, + "deleteDownloadsPrompt": "Ви дійсно хочете видалити {itemType, select, album{album} playlist{playlist} artist{artist} genre{genre} track{song} other{}} '{itemName}' з цього пристрою?", + "@deleteDownloadsPrompt": { + "placeholders": { + "itemName": { + "type": "String", + "example": "Abandon Ship" + }, + "itemType": { + "type": "String", + "example": "album" + } + }, + "description": "Confirmation prompt shown before deleting downloaded media from the local device, destructive action, doesn't affect the media on the server." + }, + "deleteDownloadsConfirmButtonText": "Видалити", + "@deleteDownloadsConfirmButtonText": { + "description": "Shown in the confirmation dialog for deleting downloaded media from the local device." + }, + "deleteDownloadsAbortButtonText": "Скасувати", + "@deleteDownloadsAbortButtonText": {}, + "insertedIntoQueue": "Вставлено в чергу.", + "@insertedIntoQueue": { + "description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end." + }, + "refresh": "ОНОВИТИ", + "@refresh": {}, + "noMusicLibrariesTitle": "Музичні бібліотеки відсутні", + "@noMusicLibrariesTitle": { + "description": "Title for message that shows on the views screen when no music libraries could be found." + }, + "noMusicLibrariesBody": "Finamp не знайшов жодної музичної бібліотеки. Переконайтеся, що ваш сервер Jellyfin містить принаймні одну бібліотеку з типом вмісту \"Музика\".", + "@noMusicLibrariesBody": {}, + "resetTabs": "Скинути вкладки", + "@resetTabs": {}, + "syncDownloadedPlaylists": "Синхронізувати завантажені плейлисти", + "@syncDownloadedPlaylists": {}, + "showFastScroller": "Показувати швидку прокрутку", + "@showFastScroller": {}, + "playNext": "Відтворити наступним", + "@playNext": { + "description": "Popup menu item title for inserting an item into the play queue after the currently-playing item." + } +} diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb new file mode 100644 index 0000000..5cefd6d --- /dev/null +++ b/lib/l10n/app_vi.arb @@ -0,0 +1,485 @@ +{ + "serverUrl": "URL của Máy chủ", + "@serverUrl": {}, + "emptyServerUrl": "URL của máy chủ không thể để trống", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "urlStartWithHttps": "URL phải bắt đầu bằng http:// hoặc https://", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "urlTrailingSlash": "URL không được có dấu gạch chéo", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "username": "Tên người dùng", + "@username": {}, + "password": "Mật khẩu", + "@password": {}, + "logs": "Nhật kí hoạt động", + "@logs": {}, + "next": "Tiếp theo", + "@next": {}, + "selectMusicLibraries": "Chọn Thư viện Nhạc", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "couldNotFindLibraries": "Không thể tìm thấy bất kì thư viện nào.", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "unknownName": "Không có tên", + "@unknownName": {}, + "songs": "Bài hát", + "@songs": {}, + "albums": "Albums", + "@albums": {}, + "artists": "Nghệ sĩ", + "@artists": {}, + "genres": "Thể loại", + "@genres": {}, + "startMix": "Bắt đầu Mix", + "@startMix": {}, + "startMixNoSongsArtist": "Bấm giữ một nghệ sĩ để thêm hoặc loại bỏ ra khỏi bộ Mix trước khi bắt đầu một tuyển tập Mix", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "music": "Nhạc", + "@music": {}, + "clear": "Loại bỏ", + "@clear": {}, + "favourites": "Yêu thích", + "@favourites": {}, + "shuffleAll": "Trộn hết", + "@shuffleAll": {}, + "finamp": "Finamp", + "@finamp": {}, + "downloads": "Tải xuống", + "@downloads": {}, + "settings": "Cài đặt", + "@settings": {}, + "offlineMode": "Chế độ ngoại tuyến", + "@offlineMode": {}, + "sortOrder": "Sắp thứ tự", + "@sortOrder": {}, + "sortBy": "Sắp theo", + "@sortBy": {}, + "album": "Album", + "@album": {}, + "albumArtist": "Album Nghệ Sĩ", + "@albumArtist": {}, + "artist": "Nghệ sĩ", + "@artist": {}, + "budget": "Chi phí", + "@budget": {}, + "criticRating": "Đánh giá nhà phê bình", + "@criticRating": {}, + "dateAdded": "Ngày được thêm", + "@dateAdded": {}, + "datePlayed": "Ngày phát", + "@datePlayed": {}, + "playCount": "Số lần phát", + "@playCount": {}, + "premiereDate": "Ngày phát hành", + "@premiereDate": {}, + "productionYear": "Năm sản xuất", + "@productionYear": {}, + "name": "Tên", + "@name": {}, + "random": "Ngẫu nhiên", + "@random": {}, + "revenue": "Lợi nhuận", + "@revenue": {}, + "runtime": "Thời gian phát", + "@runtime": {}, + "downloadMissingImages": "Tải ảnh thiếu", + "@downloadMissingImages": {}, + "playlists": "Danh sách phát", + "@playlists": {}, + "downloadCount": "{count,plural, =1{{count} đang tải xuống} other{{count} đang tải xuống}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsCount": "{count,plural,=1{{count} vật} other{{count} vật}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedImagesCount": "{count,plural,=1{{count} ảnh} other{{count} ảnh}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlComplete": "{count} đã hoàn thành", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlFailed": "{count} thất bại", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlEnqueued": "{count} đang hàng đợi", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "{count} đang chạy", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "Lỗi tải xuống", + "@downloadErrorsTitle": {}, + "noErrors": "Không có lỗi!", + "@noErrors": {}, + "errorScreenError": "Đã có lỗi xảy ra khi lấy danh sách lỗi! Bạn nên tạo một Issue trên trang Github và xoá dữ liệu ứng dụng", + "@errorScreenError": {}, + "failedToGetSongFromDownloadId": "Lấy bài hát từ ID tải Thất Bại", + "@failedToGetSongFromDownloadId": {}, + "error": "Lỗi", + "@error": {}, + "discNumber": "Đĩa {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "playButtonLabel": "PHÁT", + "@playButtonLabel": {}, + "songCount": "{count,plural,=1{{count} bài hát} other{{count} bài hát}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "required": "Được yêu cầu", + "@required": {}, + "updateButtonLabel": "CẬP NHẬT", + "@updateButtonLabel": {}, + "favourite": "Yêu thích", + "@favourite": {}, + "downloadsDeleted": "Đã xóa tải xuống.", + "@downloadsDeleted": {}, + "addDownloads": "Thêm tải xuống", + "@addDownloads": {}, + "location": "Vị trí", + "@location": {}, + "addButtonLabel": "THÊM", + "@addButtonLabel": {}, + "shareLogs": "Chia sẻ bản ghi", + "@shareLogs": {}, + "logsCopied": "Đã sao chép bản ghi.", + "@logsCopied": {}, + "message": "Tin nhắn", + "@message": {}, + "stackTrace": "Dấu vết Ngăn xếp", + "@stackTrace": {}, + "transcoding": "Chuyển Mã", + "@transcoding": {}, + "downloadLocations": "Vị trí tải xuống", + "@downloadLocations": {}, + "audioService": "Dịch vụ âm thanh", + "@audioService": {}, + "layoutAndTheme": "Bố cục & Chủ đề", + "@layoutAndTheme": {}, + "notAvailableInOfflineMode": "Không khả dụng ở chế độ ngoại tuyến", + "@notAvailableInOfflineMode": {}, + "logOut": "Đăng xuất", + "@logOut": {}, + "downloadedSongsWillNotBeDeleted": "Các bài hát được tải xuống sẽ không bị xoá", + "@downloadedSongsWillNotBeDeleted": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin dùng codec AAC để chuyển đổi", + "@jellyfinUsesAACForTranscoding": {}, + "enableTranscoding": "Bật chuyển đổi", + "@enableTranscoding": {}, + "enableTranscodingSubtitle": "Chuyển đổi các luồng truyền phát nhạc trên phía máy chủ.", + "@enableTranscodingSubtitle": {}, + "editPlaylistNameTooltip": "Chỉnh sửa tên danh sách phát", + "@editPlaylistNameTooltip": {}, + "customLocation": "Vị trí tuỳ chỉnh", + "@customLocation": {}, + "appDirectory": "Đường dẫn ứng dụng", + "@appDirectory": {}, + "addDownloadLocation": "Thêm đường dẫn tải xuống", + "@addDownloadLocation": {}, + "selectDirectory": "Chọn đường dẫn", + "@selectDirectory": {}, + "pathReturnSlashErrorMessage": "Đường dẫn mà trả về \"/\" không dùng được", + "@pathReturnSlashErrorMessage": {}, + "directoryMustBeEmpty": "Đường dẫn phải trống", + "@directoryMustBeEmpty": {}, + "enterLowPriorityStateOnPause": "Vào trạng thái Ưu Tiên Thấp khi Dừng", + "@enterLowPriorityStateOnPause": {}, + "enterLowPriorityStateOnPauseSubtitle": "Để thông báo được gạt đi khi dừng. Ngoài ra cho phép Android tắt dịch vụ khi dừng.", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "shuffleAllSongCount": "Trộn tất cả bài hát", + "@shuffleAllSongCount": {}, + "shuffleAllSongCountSubtitle": "Số lượng bài hát được tải khi dùng nút trộn tất cả bài hát.", + "@shuffleAllSongCountSubtitle": {}, + "viewTypeSubtitle": "Loại xem cho màn hình nhạc", + "@viewTypeSubtitle": {}, + "list": "Danh sách", + "@list": {}, + "addToPlaylistTooltip": "Thêm vào danh sách phát", + "@addToPlaylistTooltip": {}, + "addToPlaylistTitle": "Thêm vào Danh sách phát", + "@addToPlaylistTitle": {}, + "removeFromPlaylistTooltip": "Loại bỏ khỏi danh sách phát", + "@removeFromPlaylistTooltip": {}, + "removeFromPlaylistTitle": "Loại bỏ khỏi Danh sách phát", + "@removeFromPlaylistTitle": {}, + "newPlaylist": "Danh sách phát mới", + "@newPlaylist": {}, + "startupError": "Đã xảy ra lỗi khi khởi động ứng dụng. Lỗi: {error}\n\nXin hãy tạo một Issue trên github.com/UnicornsOnLSD/finamp với ảnh chụp màn hình của trang này. Nếu vấn đề tái diễn bạn có thể dọn dữ liệu ứng dụng để thiết lập lại ứng dụng.", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "internalExternalIpExplanation": "Nếu bạn muốn truy cập máy chủ Jellyfin từ xa, bạn cần phải dùng địa chỉ IP bên ngoài.\n\nNếu máy chủ của bạn đang mở cổng HTTP (cổng 80/443), bạn không cần phải chỉ rõ cổng. Đây có thể do máy chủ của bạn được set Proxy ngược.", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "communityRating": "Đánh giá cộng đồng", + "@communityRating": {}, + "downloadedMissingImages": "{count,plural, =0{Không ảnh thiếu được tìm} =1{Đã tải {count} ảnh thiếu} other{Đã tải {count} ảnh thiếu}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "shuffleButtonLabel": "XÁO TRỘN", + "@shuffleButtonLabel": {}, + "startMixNoSongsAlbum": "Bấm giữ một Album để thêm hoặc loại bỏ ra khỏi bộ Mix trước khi bắt đầu một tuyển tập Mix", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "downloadErrors": "Lỗi tải", + "@downloadErrors": {}, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "downloadsAdded": "Đã thêm tải xuống.", + "@downloadsAdded": {}, + "areYouSure": "Bạn chắc chứ?", + "@areYouSure": {}, + "unknownError": "Lỗi không xác định", + "@unknownError": {}, + "applicationLegalese": "Được cấp giấy phép Mozilla Public License 2.0. Mã nguồn tại:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "bitrate": "Tốc độ bit", + "@bitrate": {}, + "bitrateSubtitle": "Một tốc độ bit cao hơn mang lại âm thanh tốt hơn trong khi dùng băng thông lớn hơn.", + "@bitrateSubtitle": {}, + "customLocationsBuggy": "Vị trí tùy chỉnh khá nhiều lỗi do lỗi với việc cấp quyền. Tôi đang tìm biện pháp để sửa, hiện tại tôi không khuyến khích dùng chúng.", + "@customLocationsBuggy": {}, + "editPlaylistNameTitle": "Chỉnh sửa Tên Danh sách phát", + "@editPlaylistNameTitle": {}, + "viewType": "Loại Xem", + "@viewType": {}, + "playlistNameUpdated": "Tên danh sách phát đã được cập nhật.", + "@playlistNameUpdated": {}, + "noArtist": "Không có Nghệ sĩ", + "@noArtist": {}, + "direct": "TRỰC TIẾP", + "@direct": {}, + "addFavourite": "Thêm vào Yêu thích", + "@addFavourite": {}, + "startingInstantMix": "Bắt đầu tuyển tập nhạc tức thời.", + "@startingInstantMix": {}, + "responseError401": "{error} Mã {statusCode}. Có thể bạn đã nhập sai tên người dùng/mật khẩu, hoặc client của bạn bị đăng xuất.", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "addToMix": "Thêm vào Mix", + "@addToMix": {}, + "noButtonLabel": "KHÔNG", + "@noButtonLabel": {}, + "setSleepTimer": "Đặt đồng hồ hẹn giờ ngủ", + "@setSleepTimer": {}, + "grid": "Lưới", + "@grid": {}, + "portrait": "Dọc", + "@portrait": {}, + "landscape": "Ngang", + "@landscape": {}, + "gridCrossAxisCount": "{value} Số Lưới Trục-Chéo", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "gridCrossAxisCountSubtitle": "Số lượng lưới dùng mỗi hàng khi {value}.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "showTextOnGridViewSubtitle": "Cho dù có hay không hiện thông tin (tiêu đề, nghệ sĩ,...) trên lưới nhạc.", + "@showTextOnGridViewSubtitle": {}, + "showTextOnGridView": "Hiện thông tin trong khung lưới", + "@showTextOnGridView": {}, + "showCoverAsPlayerBackground": "Dùng ảnh bìa mờ làm nền trình phát", + "@showCoverAsPlayerBackground": {}, + "showCoverAsPlayerBackgroundSubtitle": "Có hay không dùng ảnh bìa mờ làm nền trình phát trên phần phát nhạc.", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "hideSongArtistsIfSameAsAlbumArtists": "Ẩn tên ca sĩ bài hát nếu giống ca sĩ album", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Có hay không hiện nghệ sĩ bài hát trên phần album nếu không khác ca sĩ album.", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "disableGesture": "Tắt cử chỉ", + "@disableGesture": {}, + "disableGestureSubtitle": "Có nên tắt cử chỉ.", + "@disableGestureSubtitle": {}, + "theme": "Chủ đề", + "@theme": {}, + "system": "Hệ thống", + "@system": {}, + "light": "Sáng", + "@light": {}, + "dark": "Tối", + "@dark": {}, + "tabs": "Trang", + "@tabs": {}, + "cancelSleepTimer": "Tắt Đồng hồ Hẹn Giờ Ngủ?", + "@cancelSleepTimer": {}, + "yesButtonLabel": "CÓ", + "@yesButtonLabel": {}, + "minutes": "Phút", + "@minutes": {}, + "invalidNumber": "Số không hợp lệ", + "@invalidNumber": {}, + "sleepTimerTooltip": "Đồng hồ hẹn giờ ngủ", + "@sleepTimerTooltip": {}, + "createButtonLabel": "TẠO", + "@createButtonLabel": {}, + "playlistCreated": "Đã tạo danh sách phát.", + "@playlistCreated": {}, + "noAlbum": "Không có Album", + "@noAlbum": {}, + "noItem": "Không có vật phẩm", + "@noItem": {}, + "unknownArtist": "Nghệ sĩ không rõ", + "@unknownArtist": {}, + "streaming": "STREAMING", + "@streaming": {}, + "downloaded": "ĐÃ TẢI XUỐNG", + "@downloaded": {}, + "transcode": "CHUYỂN MÃ", + "@transcode": {}, + "statusError": "LỖI TRẠNG THÁI", + "@statusError": {}, + "queue": "Hàng chờ", + "@queue": {}, + "addToQueue": "Thêm vào Hàng chờ", + "@addToQueue": {}, + "replaceQueue": "Thay thế Hàng chờ", + "@replaceQueue": {}, + "goToAlbum": "Đi tới Album", + "@goToAlbum": {}, + "removeFavourite": "Xóa yêu thích", + "@removeFavourite": {}, + "addedToQueue": "Đã thêm vào hàng chờ.", + "@addedToQueue": {}, + "queueReplaced": "Đã thay thế hàng chờ.", + "@queueReplaced": {}, + "removedFromPlaylist": "Đã loại bỏ khỏi danh sách.", + "@removedFromPlaylist": {}, + "anErrorHasOccured": "Đã có lỗi xảy ra.", + "@anErrorHasOccured": {}, + "responseError": "{error} Mã {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "redownloadedItems": "{count,plural, =0{Không cần tải lại.} =1{Đã tải lại {count} bài} other{Đã tải lại {count} bài}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "bufferDuration": "Thời gian bộ đệm", + "@bufferDuration": {}, + "bufferDurationSubtitle": "Trình phát nên tạo bộ đệm trong bao lâu giây. Cần khởi động lại app.", + "@bufferDurationSubtitle": {}, + "instantMix": "Instant Mix", + "@instantMix": {}, + "removeFromMix": "Loại bỏ khỏi Mix", + "@removeFromMix": {}, + "language": "Ngôn ngữ", + "@language": {} +} diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb new file mode 100644 index 0000000..220a1d6 --- /dev/null +++ b/lib/l10n/app_zh.arb @@ -0,0 +1,590 @@ +{ + "logs": "日志", + "@logs": {}, + "password": "密码", + "@password": {}, + "username": "用户名", + "@username": {}, + "selectMusicLibraries": "选择音乐库", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "artists": "艺术家", + "@artists": {}, + "genres": "流派", + "@genres": {}, + "songs": "歌曲", + "@songs": {}, + "unknownName": "未知名称", + "@unknownName": {}, + "playlists": "播放列表", + "@playlists": {}, + "startMix": "开始混音", + "@startMix": {}, + "favourites": "收藏夹", + "@favourites": {}, + "shuffleAll": "全部随机播放", + "@shuffleAll": {}, + "sortOrder": "排序", + "@sortOrder": {}, + "artist": "艺术家", + "@artist": {}, + "budget": "预算", + "@budget": {}, + "sortBy": "排序方式", + "@sortBy": {}, + "premiereDate": "首映日期", + "@premiereDate": {}, + "name": "名称", + "@name": {}, + "random": "随机", + "@random": {}, + "downloadCount": "{count,plural, =1{{count} 个下载} other{{count} 个下载}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedImagesCount": "{count,plural,=1{{count} 张图像} other{{count} 张图像}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsCount": "{count,plural,=1{{count} 个项目} other{{count} 个项目}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "下载错误", + "@downloadErrorsTitle": {}, + "noErrors": "没有错误!", + "@noErrors": {}, + "error": "错误", + "@error": {}, + "playButtonLabel": "播放", + "@playButtonLabel": {}, + "shuffleButtonLabel": "随机播放", + "@shuffleButtonLabel": {}, + "required": "必需的", + "@required": {}, + "updateButtonLabel": "更新", + "@updateButtonLabel": {}, + "songCount": "{count,plural,=1{{count} 首歌曲} other{{count} 首歌曲}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "addDownloads": "添加下载", + "@addDownloads": {}, + "downloadsAdded": "已添加下载。", + "@downloadsAdded": {}, + "downloadsDeleted": "已删除下载。", + "@downloadsDeleted": {}, + "location": "位置", + "@location": {}, + "playlistNameUpdated": "播放列表名称已更新。", + "@playlistNameUpdated": {}, + "addButtonLabel": "添加", + "@addButtonLabel": {}, + "message": "消息", + "@message": {}, + "shareLogs": "分享日志", + "@shareLogs": {}, + "stackTrace": "堆栈跟踪", + "@stackTrace": {}, + "transcoding": "转码", + "@transcoding": {}, + "downloadLocations": "下载位置", + "@downloadLocations": {}, + "layoutAndTheme": "布局和主题", + "@layoutAndTheme": {}, + "logOut": "登出", + "@logOut": {}, + "bitrate": "比特率", + "@bitrate": {}, + "enableTranscoding": "启用转码", + "@enableTranscoding": {}, + "bitrateSubtitle": "更高的比特率以更高的带宽为代价提供更高质量的音频。", + "@bitrateSubtitle": {}, + "appDirectory": "应用目录", + "@appDirectory": {}, + "customLocation": "自定义位置", + "@customLocation": {}, + "pathReturnSlashErrorMessage": "不能使用返回“/”的路径", + "@pathReturnSlashErrorMessage": {}, + "selectDirectory": "选择目录", + "@selectDirectory": {}, + "unknownError": "未知错误", + "@unknownError": {}, + "enterLowPriorityStateOnPause": "暂停时进入低优先级状态", + "@enterLowPriorityStateOnPause": {}, + "landscape": "横向", + "@landscape": {}, + "portrait": "纵向", + "@portrait": {}, + "viewType": "视图类型", + "@viewType": {}, + "viewTypeSubtitle": "音乐屏幕的视图类型", + "@viewTypeSubtitle": {}, + "showTextOnGridView": "在网格视图中显示文本", + "@showTextOnGridView": {}, + "showCoverAsPlayerBackgroundSubtitle": "是否在播放器屏幕上使用模糊的封面作为背景。", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "system": "系统", + "@system": {}, + "light": "浅色", + "@light": {}, + "noButtonLabel": "否", + "@noButtonLabel": {}, + "yesButtonLabel": "是", + "@yesButtonLabel": {}, + "createButtonLabel": "创建", + "@createButtonLabel": {}, + "invalidNumber": "无效数字", + "@invalidNumber": {}, + "newPlaylist": "新建播放列表", + "@newPlaylist": {}, + "noAlbum": "没有专辑", + "@noAlbum": {}, + "playlistCreated": "播放列表已创建。", + "@playlistCreated": {}, + "setSleepTimer": "设置睡眠定时器", + "@setSleepTimer": {}, + "sleepTimerTooltip": "睡眠定时器", + "@sleepTimerTooltip": {}, + "streaming": "串流", + "@streaming": {}, + "unknownArtist": "未知艺术家", + "@unknownArtist": {}, + "transcode": "转码", + "@transcode": {}, + "direct": "直接播放", + "@direct": {}, + "instantMix": "即时混音", + "@instantMix": {}, + "replaceQueue": "替换队列", + "@replaceQueue": {}, + "goToAlbum": "前往专辑", + "@goToAlbum": {}, + "queueReplaced": "队列已被替换。", + "@queueReplaced": {}, + "removeFavourite": "删除收藏夹", + "@removeFavourite": {}, + "startingInstantMix": "开始即时混音。", + "@startingInstantMix": {}, + "anErrorHasOccured": "发生了一个错误。", + "@anErrorHasOccured": {}, + "responseError": "{error} 状态码 {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "responseError401": "{error} 状态码 {statusCode}。 这可能意味着您使用了错误的用户名/密码,或者您客户端的身份验证已失效。", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "addDownloadLocation": "添加下载路径", + "@addDownloadLocation": {}, + "addedToQueue": "添加至队列。", + "@addedToQueue": {}, + "addToQueue": "添加至队列", + "@addToQueue": {}, + "addFavourite": "添加至收藏夹", + "@addFavourite": {}, + "addToPlaylistTooltip": "添加至播放列表", + "@addToPlaylistTooltip": {}, + "addToPlaylistTitle": "添加至播放列表", + "@addToPlaylistTitle": {}, + "albumArtist": "专辑艺术家", + "@albumArtist": {}, + "album": "专辑", + "@album": {}, + "albums": "专辑", + "@albums": {}, + "gridCrossAxisCountSubtitle": "每行使用的网格图块数量 {value}.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "shuffleAllSongCountSubtitle": "使用随机播放所有歌曲按钮时要加载的歌曲数量。", + "@shuffleAllSongCountSubtitle": {}, + "errorScreenError": "获取错误列表时出错! 现在您可能应该在 GitHub 上创建一个问题并清除应用数据", + "@errorScreenError": {}, + "audioService": "音频服务", + "@audioService": {}, + "areYouSure": "你确定吗?", + "@areYouSure": {}, + "cancelSleepTimer": "取消睡眠定时器?", + "@cancelSleepTimer": {}, + "clear": "清除", + "@clear": {}, + "communityRating": "社区评级", + "@communityRating": {}, + "couldNotFindLibraries": "找不到任何库。", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "dateAdded": "添加日期", + "@dateAdded": {}, + "dlComplete": "{count} 完成", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlEnqueued": "{count} 已入队", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlFailed": "{count} 失败", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "{count} 运行中", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "criticRating": "评论家评级", + "@criticRating": {}, + "downloads": "下载", + "@downloads": {}, + "customLocationsBuggy": "由于权限问题,自定义位置存在很多问题。 我正在考虑解决此问题的方法,但目前我不建议使用它们。", + "@customLocationsBuggy": {}, + "datePlayed": "播放日期", + "@datePlayed": {}, + "dark": "深色", + "@dark": {}, + "downloaded": "下载", + "@downloaded": {}, + "discNumber": "唱片 {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "downloadErrors": "下载错误", + "@downloadErrors": {}, + "directoryMustBeEmpty": "目录必须为空", + "@directoryMustBeEmpty": {}, + "downloadedSongsWillNotBeDeleted": "下载的歌曲不会被删除", + "@downloadedSongsWillNotBeDeleted": {}, + "downloadMissingImages": "下载缺少的图像", + "@downloadMissingImages": {}, + "hideSongArtistsIfSameAsAlbumArtists": "如果与专辑艺术家相同,则隐藏歌曲艺术家", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "editPlaylistNameTooltip": "编辑播放列表名称", + "@editPlaylistNameTooltip": {}, + "editPlaylistNameTitle": "编辑播放列表名称", + "@editPlaylistNameTitle": {}, + "failedToGetSongFromDownloadId": "从下载 ID 获取歌曲失败", + "@failedToGetSongFromDownloadId": {}, + "favourite": "收藏夹", + "@favourite": {}, + "grid": "网格", + "@grid": {}, + "finamp": "Finamp", + "@finamp": {}, + "enableTranscodingSubtitle": "音乐流将由服务器转码。", + "@enableTranscodingSubtitle": {}, + "music": "音乐", + "@music": {}, + "internalExternalIpExplanation": "如果您希望能够远程访问您的 Jellyfin 服务器,则需要使用您的外部 IP。\n\n如果您的服务器位于 HTTP 端口 (80/443) 上,则不必指定端口。 如果您的服务器位于反向代理后面,则可能会出现这种情况。", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "list": "列表", + "@list": {}, + "logsCopied": "已复制日志。", + "@logsCopied": {}, + "next": "下一个", + "@next": {}, + "noArtist": "没有艺术家", + "@noArtist": {}, + "playCount": "播放计数", + "@playCount": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin 使用 AAC 进行转码", + "@jellyfinUsesAACForTranscoding": {}, + "noItem": "没有项目", + "@noItem": {}, + "applicationLegalese": "获得 Mozilla 公共许可证 2.0 的许可。 源代码位于:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "startMixNoSongsAlbum": "在开始混音之前,长按专辑以在混音生成器中添加或删除它们", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "serverUrl": "服务器 URL", + "@serverUrl": {}, + "startMixNoSongsArtist": "在开始混音之前,长按艺术家以在混音生成器中添加或删除它们", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "notAvailableInOfflineMode": "在离线模式下不可用", + "@notAvailableInOfflineMode": {}, + "offlineMode": "离线模式", + "@offlineMode": {}, + "queue": "队列", + "@queue": {}, + "productionYear": "制作年份", + "@productionYear": {}, + "showCoverAsPlayerBackground": "将模糊的封面应用为播放器背景", + "@showCoverAsPlayerBackground": {}, + "revenue": "收入", + "@revenue": {}, + "runtime": "运行时", + "@runtime": {}, + "settings": "设置", + "@settings": {}, + "emptyServerUrl": "服务器 URL 不能为空", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "shuffleAllSongCount": "随机播放所有歌曲计数", + "@shuffleAllSongCount": {}, + "startupError": "应用程序启动期间出现问题! 错误是:{error}\n\n请在 github.com/UnicornsOnLSD/finamp 上创建一个 Github 问题,并附上此页面的屏幕截图。 如果问题一直显示,请清除您的应用数据以重置应用。", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "tabs": "选项卡", + "@tabs": {}, + "statusError": "状态错误", + "@statusError": {}, + "theme": "主题", + "@theme": {}, + "urlTrailingSlash": "URL 不得包含尾部斜杠", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "urlStartWithHttps": "URL 必须以 http:// 或 https:// 开头", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "gridCrossAxisCount": "{value} 网格横轴数量", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "enterLowPriorityStateOnPauseSubtitle": "通知可以在暂停时滑动。 启用此功能还允许 Android 在暂停时终止服务。", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "showTextOnGridViewSubtitle": "是否在网格音乐屏幕上显示文本(标题、艺术家等)。", + "@showTextOnGridViewSubtitle": {}, + "downloadedMissingImages": "{count,plural, =0{未找到缺少的图像} =1{已下载 {count} 张缺少的图形} other{已下载 {count} 张缺少的图像}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "是否在专辑屏幕上隐藏歌曲艺术家(如果他们与专辑艺术家没有区别)。", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "removeFromMix": "从混音中删除", + "@removeFromMix": {}, + "addToMix": "添加到混音", + "@addToMix": {}, + "minutes": "分钟", + "@minutes": {}, + "bufferDuration": "媒体时长", + "@bufferDuration": {}, + "disableGesture": "禁用手势", + "@disableGesture": {}, + "disableGestureSubtitle": "是否禁用手势。", + "@disableGestureSubtitle": {}, + "redownloadedItems": "{count,plural, =0{无需重新下载项目} =1{{count} 项重新下载的项目} other{{count} 项重新下载的项目}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "bufferDurationSubtitle": "播放器需要提前缓冲多长时间的音乐,以秒为单位。需要重启才能生效。", + "@bufferDurationSubtitle": {}, + "removedFromPlaylist": "已从播放列表删除。", + "@removedFromPlaylist": {}, + "removeFromPlaylistTitle": "从播放列表删除", + "@removeFromPlaylistTitle": {}, + "removeFromPlaylistTooltip": "从播放列表删除", + "@removeFromPlaylistTooltip": {}, + "language": "语言", + "@language": {}, + "confirm": "确认", + "@confirm": {}, + "showUncensoredLogMessage": "该日志包含您的登录信息。显示?", + "@showUncensoredLogMessage": {}, + "resetTabs": "重置选项卡", + "@resetTabs": {}, + "insertedIntoQueue": "已插入队列。", + "@insertedIntoQueue": { + "description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end." + }, + "refresh": "刷新", + "@refresh": {}, + "playNext": "播放下一个", + "@playNext": { + "description": "Popup menu item title for inserting an item into the play queue after the currently-playing item." + }, + "noMusicLibrariesBody": "Finamp 找不到任何音乐库。请确保您的 Jellyfin 服务器至少包含一个内容类型设置为“音乐”的媒体库。", + "@noMusicLibrariesBody": {}, + "noMusicLibrariesTitle": "没有音乐库", + "@noMusicLibrariesTitle": { + "description": "Title for message that shows on the views screen when no music libraries could be found." + }, + "deleteDownloadsConfirmButtonText": "删除", + "@deleteDownloadsConfirmButtonText": { + "description": "Shown in the confirmation dialog for deleting downloaded media from the local device." + }, + "deleteDownloadsAbortButtonText": "取消", + "@deleteDownloadsAbortButtonText": {}, + "deleteDownloadsPrompt": "您确定要从此设备中删除 {itemType, select, album{album} playlist{playlist} artist{artist} genre{genre} track{song} other{}} '{itemName}'吗?", + "@deleteDownloadsPrompt": { + "placeholders": { + "itemName": { + "type": "String", + "example": "Abandon Ship" + }, + "itemType": { + "type": "String", + "example": "album" + } + }, + "description": "Confirmation prompt shown before deleting downloaded media from the local device, destructive action, doesn't affect the media on the server." + }, + "syncDownloadedPlaylists": "同步下载的播放列表", + "@syncDownloadedPlaylists": {}, + "showFastScroller": "显示快速滚动条", + "@showFastScroller": {}, + "swipeInsertQueueNext": "播放滑动的下一首歌曲", + "@swipeInsertQueueNext": {}, + "interactions": "交互", + "@interactions": {}, + "swipeInsertQueueNextSubtitle": "在歌曲列表中滑动时,可以将歌曲作为队列中的下一个项目插入,而不是将其附加到末尾。", + "@swipeInsertQueueNextSubtitle": {}, + "redesignBeta": "试用 Beta 版", + "@redesignBeta": {}, + "loopModeOneTooltip": "循环播放一首。点击即可切换。", + "@loopModeOneTooltip": {}, + "loopModeNoneTooltip": "不循环。点击即可切换。", + "@loopModeNoneTooltip": {}, + "skipToNext": "跳至下一首歌曲", + "@skipToNext": {}, + "togglePlayback": "切换播放", + "@togglePlayback": {}, + "playArtist": "播放 {artist} 的所有专辑", + "@playArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "shuffleArtist": "随机播放 {artist} 的所有专辑", + "@shuffleArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "deleteFromDevice": "从设备中删除", + "@deleteFromDevice": {}, + "playbackOrderShuffledTooltip": "随机播放。点击即可切换。", + "@playbackOrderShuffledTooltip": {}, + "playbackOrderLinearTooltip": "按顺序播放。点击即可切换。", + "@playbackOrderLinearTooltip": {}, + "loopModeAllTooltip": "全部循环播放。点击即可切换。", + "@loopModeAllTooltip": {}, + "download": "下载", + "@download": {}, + "skipToPrevious": "跳至上一首歌曲", + "@skipToPrevious": {}, + "downloadArtist": "下载 {artist} 的所有专辑", + "@downloadArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "sync": "与服务器同步", + "@sync": {}, + "about": "关于 Finamp", + "@about": {} +} diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb new file mode 100644 index 0000000..aa2a0f0 --- /dev/null +++ b/lib/l10n/app_zh_Hant.arb @@ -0,0 +1,539 @@ +{ + "serverUrl": "伺服器 URL", + "@serverUrl": {}, + "showTextOnGridViewSubtitle": "是否在網格音樂螢幕上顯示文字(標題、歌手等)。", + "@showTextOnGridViewSubtitle": {}, + "startupError": "應用程序啓動期間出現問題! 錯誤是:{error}\n\n請在 github.com/UnicornsOnLSD/finamp 上提出一個 Github 問題,並附上此頁面的螢幕截圖。 如果此頁面一直顯示,請清除您的應用數據以重置應用。", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "internalExternalIpExplanation": "如果您希望能夠遠程訪問您的 Jellyfin 伺服器,則需要使用您的外部 IP。\n\n如果您的伺服器位於 HTTP 端口 (80/443) 上,則不必指定端口。 如果您的伺服器位於反向代理後面,則可能會出現這種情況。", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "emptyServerUrl": "伺服器 URL 不能為空", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "urlStartWithHttps": "URL 必須以 http:// 或 https:// 開頭", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "urlTrailingSlash": "URL 不得包含斜線號", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "username": "用戶名", + "@username": {}, + "password": "密碼", + "@password": {}, + "logs": "紀錄檔", + "@logs": {}, + "next": "下一個", + "@next": {}, + "selectMusicLibraries": "選擇音樂庫", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "couldNotFindLibraries": "找不到任何庫。", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "unknownName": "未知名稱", + "@unknownName": {}, + "songs": "歌曲", + "@songs": {}, + "albums": "專輯", + "@albums": {}, + "artists": "藝術家", + "@artists": {}, + "genres": "曲風", + "@genres": {}, + "playlists": "播放列表", + "@playlists": {}, + "startMix": "開始混音", + "@startMix": {}, + "startMixNoSongsArtist": "在開始混音之前,長按藝術家以在混音生成器中添加或刪除它們", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "startMixNoSongsAlbum": "在開始混音之前,長按專輯以在混音生成器中添加或刪除它們", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "music": "音樂", + "@music": {}, + "clear": "清除", + "@clear": {}, + "favourites": "最愛", + "@favourites": {}, + "shuffleAll": "全部隨機播放", + "@shuffleAll": {}, + "finamp": "Finamp", + "@finamp": {}, + "downloads": "下載", + "@downloads": {}, + "settings": "設置", + "@settings": {}, + "offlineMode": "離線模式", + "@offlineMode": {}, + "sortOrder": "排序", + "@sortOrder": {}, + "sortBy": "排序方式", + "@sortBy": {}, + "album": "專輯", + "@album": {}, + "albumArtist": "專輯歌手", + "@albumArtist": {}, + "artist": "歌手", + "@artist": {}, + "budget": "預算", + "@budget": {}, + "communityRating": "聽眾評級", + "@communityRating": {}, + "criticRating": "評論家評級", + "@criticRating": {}, + "dateAdded": "添加日期", + "@dateAdded": {}, + "datePlayed": "播放日期", + "@datePlayed": {}, + "playCount": "播放計數", + "@playCount": {}, + "premiereDate": "首播日期", + "@premiereDate": {}, + "productionYear": "製作年份", + "@productionYear": {}, + "name": "名稱", + "@name": {}, + "random": "隨機", + "@random": {}, + "revenue": "收入", + "@revenue": {}, + "runtime": "運行時", + "@runtime": {}, + "downloadMissingImages": "下載缺少的圖片", + "@downloadMissingImages": {}, + "downloadedImagesCount": "{count,plural,=1{{count} 張圖片} other{{count} 張圖片}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedMissingImages": "{count,plural, =0{未找到缺少的圖片} =1{已下載 {count} 張缺少的圖片} other{已下載 {count} 張缺少的圖片}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "downloadErrors": "下載錯誤", + "@downloadErrors": {}, + "downloadCount": "{count,plural, =1{{count} 個下載} other{{count} 個下載}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsCount": "{count,plural,=1{{count} 個項目} other{{count} 個項目}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlComplete": "{count} 完成", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlFailed": "{count} 失敗", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlEnqueued": "{count} 已入隊", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "{count} 運行中", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "下載錯誤", + "@downloadErrorsTitle": {}, + "noErrors": "沒有錯誤!", + "@noErrors": {}, + "errorScreenError": "獲取錯誤列表時出錯!您可以在 GitHub 上提出問題並清除應用數據", + "@errorScreenError": {}, + "failedToGetSongFromDownloadId": "從下載 ID 獲取歌曲失敗", + "@failedToGetSongFromDownloadId": {}, + "error": "錯誤", + "@error": {}, + "discNumber": "唱片 {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "playButtonLabel": "播放", + "@playButtonLabel": {}, + "shuffleButtonLabel": "隨機播放", + "@shuffleButtonLabel": {}, + "songCount": "{count,plural,=1{{count} 首歌曲} other{{count} 首歌曲}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "editPlaylistNameTooltip": "編輯播放列表名稱", + "@editPlaylistNameTooltip": {}, + "editPlaylistNameTitle": "編輯播放列表名稱", + "@editPlaylistNameTitle": {}, + "required": "必需的", + "@required": {}, + "updateButtonLabel": "更新", + "@updateButtonLabel": {}, + "playlistNameUpdated": "播放列表名稱已更新。", + "@playlistNameUpdated": {}, + "favourite": "最愛", + "@favourite": {}, + "downloadsDeleted": "已刪除下載。", + "@downloadsDeleted": {}, + "addDownloads": "添加下載", + "@addDownloads": {}, + "location": "位置", + "@location": {}, + "downloadsAdded": "已添加下載。", + "@downloadsAdded": {}, + "addButtonLabel": "添加", + "@addButtonLabel": {}, + "shareLogs": "分享紀錄檔", + "@shareLogs": {}, + "applicationLegalese": "獲得 Mozilla 公共授權條款 2.0 的許可。 源代碼位於:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "logsCopied": "已複制紀錄檔。", + "@logsCopied": {}, + "message": "消息", + "@message": {}, + "stackTrace": "堆棧跟蹤", + "@stackTrace": {}, + "transcoding": "轉碼", + "@transcoding": {}, + "downloadLocations": "下載位置", + "@downloadLocations": {}, + "audioService": "音頻服務", + "@audioService": {}, + "layoutAndTheme": "佈局和主題", + "@layoutAndTheme": {}, + "notAvailableInOfflineMode": "在離線模式下不可用", + "@notAvailableInOfflineMode": {}, + "logOut": "登出", + "@logOut": {}, + "downloadedSongsWillNotBeDeleted": "下載的歌曲不會被刪除", + "@downloadedSongsWillNotBeDeleted": {}, + "areYouSure": "你確定嗎?", + "@areYouSure": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin 使用 AAC 進行轉碼", + "@jellyfinUsesAACForTranscoding": {}, + "enableTranscoding": "啓用轉碼", + "@enableTranscoding": {}, + "enableTranscodingSubtitle": "如果啓用,音樂流將由伺服器轉碼。", + "@enableTranscodingSubtitle": {}, + "bitrate": "位元速率", + "@bitrate": {}, + "bitrateSubtitle": "更高的位元速率以更高的帶寬為代價提供更高質量的音頻。", + "@bitrateSubtitle": {}, + "customLocation": "自定義位置", + "@customLocation": {}, + "appDirectory": "應用目錄", + "@appDirectory": {}, + "addDownloadLocation": "添加下載路徑", + "@addDownloadLocation": {}, + "selectDirectory": "選擇目錄", + "@selectDirectory": {}, + "unknownError": "未知錯誤", + "@unknownError": {}, + "pathReturnSlashErrorMessage": "不能使用返回“/”的路徑", + "@pathReturnSlashErrorMessage": {}, + "directoryMustBeEmpty": "目錄必須為空", + "@directoryMustBeEmpty": {}, + "customLocationsBuggy": "由於權限問題,自定義位置存在很多問題。 我正在考慮解決此問題的方法,但目前我不建議使用它們。", + "@customLocationsBuggy": {}, + "enterLowPriorityStateOnPause": "暫停時進入低優先級狀態", + "@enterLowPriorityStateOnPause": {}, + "enterLowPriorityStateOnPauseSubtitle": "啓用後,通知可以在暫停時滑動。 啓用此功能還允許 Android 在暫停時終止服務。", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "shuffleAllSongCount": "隨機播放所有歌曲計數", + "@shuffleAllSongCount": {}, + "shuffleAllSongCountSubtitle": "使用隨機播放所有歌曲按鈕時要加載的歌曲數量。", + "@shuffleAllSongCountSubtitle": {}, + "viewType": "顯示類型", + "@viewType": {}, + "viewTypeSubtitle": "音樂螢幕的顯示類型", + "@viewTypeSubtitle": {}, + "list": "列表", + "@list": {}, + "grid": "網格", + "@grid": {}, + "portrait": "縱向", + "@portrait": {}, + "landscape": "橫向", + "@landscape": {}, + "gridCrossAxisCount": "{value} 網格橫軸數量", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "gridCrossAxisCountSubtitle": "每行使用的網格圖塊數量 {value}.", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "showTextOnGridView": "在網格顯示上顯示文字", + "@showTextOnGridView": {}, + "showCoverAsPlayerBackground": "將模糊的封面應用為播放器背景", + "@showCoverAsPlayerBackground": {}, + "showCoverAsPlayerBackgroundSubtitle": "是否在播放器螢幕上使用模糊的封面作為背景。", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "hideSongArtistsIfSameAsAlbumArtists": "如果與專輯藝術家相同,則隱藏歌曲藝術家", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "是否在專輯螢幕上隱藏歌曲歌手(如果他們與專輯歌手沒有區別)。", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "theme": "主題", + "@theme": {}, + "system": "系統", + "@system": {}, + "light": "淺色", + "@light": {}, + "dark": "深色", + "@dark": {}, + "tabs": "選項卡", + "@tabs": {}, + "cancelSleepTimer": "取消睡眠定時器?", + "@cancelSleepTimer": {}, + "yesButtonLabel": "是", + "@yesButtonLabel": {}, + "noButtonLabel": "否", + "@noButtonLabel": {}, + "setSleepTimer": "設置睡眠定時器", + "@setSleepTimer": {}, + "minutes": "分鍾", + "@minutes": {}, + "invalidNumber": "無效數字", + "@invalidNumber": {}, + "sleepTimerTooltip": "睡眠定時器", + "@sleepTimerTooltip": {}, + "addToPlaylistTooltip": "添加至播放列表", + "@addToPlaylistTooltip": {}, + "addToPlaylistTitle": "添加至播放列表", + "@addToPlaylistTitle": {}, + "newPlaylist": "新建播放列表", + "@newPlaylist": {}, + "createButtonLabel": "創建", + "@createButtonLabel": {}, + "playlistCreated": "播放列表已創建。", + "@playlistCreated": {}, + "noAlbum": "沒有專輯", + "@noAlbum": {}, + "noItem": "沒有項目", + "@noItem": {}, + "noArtist": "沒有歌手", + "@noArtist": {}, + "unknownArtist": "未知歌手", + "@unknownArtist": {}, + "streaming": "串流", + "@streaming": {}, + "downloaded": "下載", + "@downloaded": {}, + "transcode": "轉碼", + "@transcode": {}, + "direct": "直接播放", + "@direct": {}, + "statusError": "狀態錯誤", + "@statusError": {}, + "queue": "隊列", + "@queue": {}, + "addToQueue": "添加至隊列", + "@addToQueue": {}, + "replaceQueue": "替換隊列", + "@replaceQueue": {}, + "instantMix": "即時混音", + "@instantMix": {}, + "goToAlbum": "前往專輯", + "@goToAlbum": {}, + "removeFavourite": "刪除最愛", + "@removeFavourite": {}, + "addFavourite": "添加至最愛", + "@addFavourite": {}, + "addedToQueue": "添加至隊列。", + "@addedToQueue": {}, + "queueReplaced": "隊列已被替換。", + "@queueReplaced": {}, + "startingInstantMix": "開始即時混音。", + "@startingInstantMix": {}, + "anErrorHasOccured": "發生了一個錯誤。", + "@anErrorHasOccured": {}, + "responseError": "{error} 狀態碼 {statusCode}.", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "responseError401": "{error} 狀態碼 {statusCode}。 這可能意味著您使用了錯誤的用戶名/密碼,或者您客戶端的身份驗證已失效。", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "removeFromMix": "從混音中刪除", + "@removeFromMix": {}, + "addToMix": "添加到混音", + "@addToMix": {}, + "disableGesture": "禁用手勢", + "@disableGesture": {}, + "disableGestureSubtitle": "是否禁用「手勢」功能。", + "@disableGestureSubtitle": {}, + "removeFromPlaylistTooltip": "從播放列表中移除歌曲", + "@removeFromPlaylistTooltip": {}, + "removeFromPlaylistTitle": "從播放列表中移除", + "@removeFromPlaylistTitle": {}, + "removedFromPlaylist": "已從播放列表中移除。", + "@removedFromPlaylist": {}, + "bufferDurationSubtitle": "播放器可以預先載入多少的音樂(秒)。需重啟以套用設定。", + "@bufferDurationSubtitle": {}, + "bufferDuration": "緩衝時長", + "@bufferDuration": {}, + "redownloadedItems": "{count,plural, =0{沒有需要重新下載的項目。} =1{已重新下載{count}個項目} other{已重新下載{count}個項目}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "language": "語言", + "@language": {}, + "showUncensoredLogMessage": "此記錄包含你的登入資訊。是否顯示?", + "@showUncensoredLogMessage": {}, + "insertedIntoQueue": "完成加入至待播清單。", + "@insertedIntoQueue": { + "description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end." + }, + "refresh": "重新整理", + "@refresh": {}, + "confirm": "確認", + "@confirm": {}, + "playNext": "插播為下一首", + "@playNext": { + "description": "Popup menu item title for inserting an item into the play queue after the currently-playing item." + }, + "resetTabs": "重設分頁", + "@resetTabs": {}, + "noMusicLibrariesBody": "找不到音樂庫。請確保 Jellyfin 中至少有一個資料庫的類別需設置成\"音樂\"。", + "@noMusicLibrariesBody": {}, + "noMusicLibrariesTitle": "尚無音樂庫", + "@noMusicLibrariesTitle": { + "description": "Title for message that shows on the views screen when no music libraries could be found." + }, + "redesignBeta": "重新設計的Beta測試版", + "@redesignBeta": {}, + "deleteDownloadsPrompt": "您確定要將 {itemType, select, album{album} playlist{playlist} artist{artist} genre{genre} track{song} other{}} '{itemName}'從設備中刪除嗎?", + "@deleteDownloadsPrompt": { + "placeholders": { + "itemName": { + "type": "String", + "example": "Abandon Ship" + }, + "itemType": { + "type": "String", + "example": "album" + } + }, + "description": "Confirmation prompt shown before deleting downloaded media from the local device, destructive action, doesn't affect the media on the server." + }, + "deleteDownloadsConfirmButtonText": "刪除", + "@deleteDownloadsConfirmButtonText": { + "description": "Shown in the confirmation dialog for deleting downloaded media from the local device." + }, + "deleteDownloadsAbortButtonText": "取消", + "@deleteDownloadsAbortButtonText": {}, + "syncDownloadedPlaylists": "同步已下載的播放清", + "@syncDownloadedPlaylists": {}, + "interactions": "互動", + "@interactions": {}, + "showFastScroller": "顯示快速滾動條", + "@showFastScroller": {}, + "swipeInsertQueueNext": "滑動歌曲接著播放", + "@swipeInsertQueueNext": {}, + "swipeInsertQueueNextSubtitle": "開啟後滑動清單中的歌曲,可以將歌曲接著播放,而不是加到播放清單的最後一首。", + "@swipeInsertQueueNextSubtitle": {} +} diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb new file mode 100644 index 0000000..a6c2c5b --- /dev/null +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -0,0 +1,578 @@ +{ + "serverUrl": "伺服器 URL", + "@serverUrl": {}, + "emptyServerUrl": "伺服器 URL 並不能漏空", + "@emptyServerUrl": { + "description": "Error message that shows when the user submits a login without a server URL" + }, + "urlStartWithHttps": "URL 必須以 http:// 或 https:// 開頭", + "@urlStartWithHttps": { + "description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0" + }, + "urlTrailingSlash": "URL 不能以「/」作結", + "@urlTrailingSlash": { + "description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)" + }, + "username": "用戶名稱", + "@username": {}, + "password": "密碼", + "@password": {}, + "unknownName": "未知的名稱", + "@unknownName": {}, + "songs": "歌曲", + "@songs": {}, + "albums": "專輯", + "@albums": {}, + "artists": "歌手", + "@artists": {}, + "genres": "曲風", + "@genres": {}, + "playlists": "播放清單", + "@playlists": {}, + "startMix": "開始混音", + "@startMix": {}, + "startMixNoSongsArtist": "在開始混音之前,長按歌手以添加至混音器或從混音器移除", + "@startMixNoSongsArtist": { + "description": "Snackbar message that shows when the user presses the instant mix button with no artists selected" + }, + "music": "音樂", + "@music": {}, + "clear": "清除", + "@clear": {}, + "downloads": "下載", + "@downloads": {}, + "settings": "設定", + "@settings": {}, + "offlineMode": "離線模式", + "@offlineMode": {}, + "sortBy": "排序方式", + "@sortBy": {}, + "sortOrder": "順序", + "@sortOrder": {}, + "album": "專輯", + "@album": {}, + "albumArtist": "專輯歌手", + "@albumArtist": {}, + "artist": "歌手", + "@artist": {}, + "communityRating": "聽眾評分", + "@communityRating": {}, + "criticRating": "樂評人評分", + "@criticRating": {}, + "dateAdded": "添加日期", + "@dateAdded": {}, + "datePlayed": "播放日期", + "@datePlayed": {}, + "playCount": "播放次數", + "@playCount": {}, + "premiereDate": "推出日期", + "@premiereDate": {}, + "productionYear": "製作年份", + "@productionYear": {}, + "name": "名稱", + "@name": {}, + "random": "隨機", + "@random": {}, + "runtime": "執行時", + "@runtime": {}, + "downloadMissingImages": "下載缺失的圖片", + "@downloadMissingImages": {}, + "budget": "預算", + "@budget": {}, + "revenue": "收入", + "@revenue": {}, + "downloadCount": "{count,plural, =1{{count}個下載} other{{count}個下載}}", + "@downloadCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsCount": "{count,plural,=1{{count}個項目} other{{count}個項目}}", + "@downloadedItemsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlComplete": "{count} 完成", + "@dlComplete": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlEnqueued": "{count} 等待中", + "@dlEnqueued": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "dlRunning": "{count} 正在下載", + "@dlRunning": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadErrorsTitle": "下載錯誤", + "@downloadErrorsTitle": {}, + "noErrors": "沒有錯誤!", + "@noErrors": {}, + "errorScreenError": "讀取錯誤資訊時發生錯誤!建議在 GitHub 上回報此問題及重設應用程式。", + "@errorScreenError": {}, + "failedToGetSongFromDownloadId": "無法從下載 ID 中取得歌曲", + "@failedToGetSongFromDownloadId": {}, + "error": "錯誤", + "@error": {}, + "discNumber": "CD {number}", + "@discNumber": { + "placeholders": { + "number": { + "type": "int" + } + } + }, + "playButtonLabel": "播放", + "@playButtonLabel": {}, + "shuffleButtonLabel": "隨機播放", + "@shuffleButtonLabel": {}, + "songCount": "{count,plural,=1{{count}首歌曲} other{{count}首歌曲}}", + "@songCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "editPlaylistNameTooltip": "編輯播放清單名稱", + "@editPlaylistNameTooltip": {}, + "editPlaylistNameTitle": "編輯播放清單名稱", + "@editPlaylistNameTitle": {}, + "updateButtonLabel": "更新", + "@updateButtonLabel": {}, + "playlistNameUpdated": "已更新播放清單名稱。", + "@playlistNameUpdated": {}, + "favourite": "我的最愛", + "@favourite": {}, + "location": "位置", + "@location": {}, + "addButtonLabel": "加入", + "@addButtonLabel": {}, + "shareLogs": "分享所有紀錄檔", + "@shareLogs": {}, + "logsCopied": "已複製所有紀錄。", + "@logsCopied": {}, + "stackTrace": "除錯資訊(Stack Trace)", + "@stackTrace": {}, + "applicationLegalese": "採用 Mozilla Public License 2.0 特許條款。原始碼:\n\ngithub.com/jmshrv/finamp", + "@applicationLegalese": {}, + "transcoding": "轉碼(Transcoding)", + "@transcoding": {}, + "downloadLocations": "下載位置", + "@downloadLocations": {}, + "audioService": "播放設定", + "@audioService": {}, + "layoutAndTheme": "顯示及主題", + "@layoutAndTheme": {}, + "notAvailableInOfflineMode": "不能在離線模式下使用", + "@notAvailableInOfflineMode": {}, + "logOut": "登出", + "@logOut": {}, + "areYouSure": "您確定嗎?", + "@areYouSure": {}, + "jellyfinUsesAACForTranscoding": "Jellyfin 使用 ACC 進行轉碼", + "@jellyfinUsesAACForTranscoding": {}, + "enableTranscodingSubtitle": "啟用後,音訊會先在伺服器轉碼。", + "@enableTranscodingSubtitle": {}, + "bitrate": "位元速率(Bitrate)", + "@bitrate": {}, + "bitrateSubtitle": "越高的位元速率帶來越好的音質,但亦會使用更多的流量。", + "@bitrateSubtitle": {}, + "addDownloadLocation": "添加下載位置", + "@addDownloadLocation": {}, + "unknownError": "未知的錯誤", + "@unknownError": {}, + "directoryMustBeEmpty": "所選的資料夾必須為空的", + "@directoryMustBeEmpty": {}, + "selectDirectory": "選擇資料夾", + "@selectDirectory": {}, + "appDirectory": "應用程式資料夾", + "@appDirectory": {}, + "enterLowPriorityStateOnPause": "暫停播放時會進入「低優先」狀態", + "@enterLowPriorityStateOnPause": {}, + "enterLowPriorityStateOnPauseSubtitle": "在停止播放時,允許本程式的「通知」能被掃走及關閉應用程式(適用於 Android 裝置)。", + "@enterLowPriorityStateOnPauseSubtitle": {}, + "shuffleAllSongCount": "隨機播放上限", + "@shuffleAllSongCount": {}, + "shuffleAllSongCountSubtitle": "使用「隨機播放全部」時,播放歌曲的數量上限。", + "@shuffleAllSongCountSubtitle": {}, + "message": "訊息", + "@message": {}, + "pathReturnSlashErrorMessage": "不能使用「/」路徑", + "@pathReturnSlashErrorMessage": {}, + "portrait": "直向", + "@portrait": {}, + "gridCrossAxisCountSubtitle": "屏幕在{value}顯示時,每行顯示的資訊數量(例如歌曲、歌手等)。", + "@gridCrossAxisCountSubtitle": { + "description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "landscape" + } + } + }, + "showTextOnGridView": "在網絡內顯示文字", + "@showTextOnGridView": {}, + "showTextOnGridViewSubtitle": "使用格狀顯示時,在網格內顯示歌曲資訊(名稱、歌手等)。", + "@showTextOnGridViewSubtitle": {}, + "showCoverAsPlayerBackground": "模糊化封面作為播放器的背景", + "@showCoverAsPlayerBackground": {}, + "showCoverAsPlayerBackgroundSubtitle": "以模糊化的專輯封面作為應用程式內的播放頁面的背景。", + "@showCoverAsPlayerBackgroundSubtitle": {}, + "hideSongArtistsIfSameAsAlbumArtists": "隱藏與專輯歌手同名的歌手名稱", + "@hideSongArtistsIfSameAsAlbumArtists": {}, + "hideSongArtistsIfSameAsAlbumArtistsSubtitle": "當專輯的歌手與歌曲的歌手相同時,隱藏歌曲的歌手名稱。", + "@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {}, + "disableGesture": "禁用「手勢」功能", + "@disableGesture": {}, + "disableGestureSubtitle": "是否禁用「手勢」功能。", + "@disableGestureSubtitle": {}, + "theme": "色彩主題", + "@theme": {}, + "system": "系統", + "@system": {}, + "dark": "深色", + "@dark": {}, + "tabs": "分頁", + "@tabs": {}, + "cancelSleepTimer": "取消睡眠定時器?", + "@cancelSleepTimer": {}, + "yesButtonLabel": "是", + "@yesButtonLabel": {}, + "noButtonLabel": "否", + "@noButtonLabel": {}, + "setSleepTimer": "設定睡眠定時器", + "@setSleepTimer": {}, + "minutes": "分鐘", + "@minutes": {}, + "invalidNumber": "無效的數字", + "@invalidNumber": {}, + "sleepTimerTooltip": "睡眠定時器", + "@sleepTimerTooltip": {}, + "addToPlaylistTooltip": "將歌曲加入至播放清單", + "@addToPlaylistTooltip": {}, + "removeFromPlaylistTooltip": "從播放清單中移除歌曲", + "@removeFromPlaylistTooltip": {}, + "removeFromPlaylistTitle": "從播放清單中移除", + "@removeFromPlaylistTitle": {}, + "newPlaylist": "建立播放清單", + "@newPlaylist": {}, + "createButtonLabel": "建立", + "@createButtonLabel": {}, + "playlistCreated": "已建立播放清單。", + "@playlistCreated": {}, + "unknownArtist": "未知的歌手", + "@unknownArtist": {}, + "direct": "直接播放", + "@direct": {}, + "streaming": "串流中", + "@streaming": {}, + "statusError": "錯誤", + "@statusError": {}, + "addToQueue": "加入至播放佇列", + "@addToQueue": {}, + "queue": "播放佇列", + "@queue": {}, + "replaceQueue": "取代現時的播放佇列", + "@replaceQueue": {}, + "instantMix": "即時混音", + "@instantMix": {}, + "goToAlbum": "檢視專輯", + "@goToAlbum": {}, + "removeFavourite": "從我的最愛中移除", + "@removeFavourite": {}, + "addFavourite": "加入至我的最愛", + "@addFavourite": {}, + "addedToQueue": "已加入至播放佇列。", + "@addedToQueue": {}, + "queueReplaced": "已取代現有的播放佇列。", + "@queueReplaced": {}, + "removedFromPlaylist": "已從播放清單中移除。", + "@removedFromPlaylist": {}, + "startingInstantMix": "開始即時混音中。", + "@startingInstantMix": {}, + "anErrorHasOccured": "出現錯誤。", + "@anErrorHasOccured": {}, + "responseError": "{error}(代碼:{statusCode})。", + "@responseError": { + "placeholders": { + "error": { + "type": "String", + "example": "Forbidden" + }, + "statusCode": { + "type": "int", + "example": "403" + } + } + }, + "removeFromMix": "從混音中移除", + "@removeFromMix": {}, + "addToMix": "加入至混音", + "@addToMix": {}, + "redownloadedItems": "{count,plural, =0{沒有需要重新下載的項目。} =1{已重新下載{count}個項目} other{已重新下載{count}個項目}}", + "@redownloadedItems": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "bufferDuration": "緩衝時長", + "@bufferDuration": {}, + "bufferDurationSubtitle": "播放器可以預先載入多少的音訊數據(秒)。重啟以套用設定。", + "@bufferDurationSubtitle": {}, + "startupError": "應用程式啟動時出錯({error})\n\n您可以在 github.com/UnicornsOnLSD/finamp 回報有關問題並附上截圖。如果問題持續,您可以嘗試重設應用程式。", + "@startupError": { + "description": "The error message that shows when startup fails.", + "placeholders": { + "error": { + "type": "String", + "example": "Failed to open download DB" + } + } + }, + "downloadedMissingImages": "{count,plural, =0{沒有缺少的圖片} =1{已下載{count}張圖片} other{已下載{count}張圖片}}", + "@downloadedMissingImages": { + "description": "Message that shows when the user downloads missing images", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "selectMusicLibraries": "選擇音樂媒體庫", + "@selectMusicLibraries": { + "description": "App bar title for library select screen" + }, + "couldNotFindLibraries": "沒有可用的媒體庫。", + "@couldNotFindLibraries": { + "description": "Error message when the user does not have any libraries" + }, + "internalExternalIpExplanation": "如果您需要在局部區域網絡(LAN)以外的地方連接 Jellyfin,請使用伺服器的區域網絡(WAN)IP。\n\n如果目標伺服器使用的連接埠(port)是 HTTP 的預設連接埠(80/433),則毋須填寫連接埠。", + "@internalExternalIpExplanation": { + "description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port." + }, + "logs": "紀錄檔", + "@logs": {}, + "next": "下一個", + "@next": {}, + "startMixNoSongsAlbum": "在開始混音之前,長按專輯以添加至混音器或從混音器移除", + "@startMixNoSongsAlbum": { + "description": "Snackbar message that shows when the user presses the instant mix button with no albums selected" + }, + "favourites": "我的最愛", + "@favourites": {}, + "shuffleAll": "隨機播放全部", + "@shuffleAll": {}, + "finamp": "Finamp", + "@finamp": {}, + "downloadErrors": "下載錯誤", + "@downloadErrors": {}, + "dlFailed": "{count} 失敗", + "@dlFailed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}", + "@downloadedItemsImagesCount": { + "description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906", + "placeholders": { + "downloadedItems": { + "type": "String", + "example": "12 downloads" + }, + "downloadedImages": { + "type": "String", + "example": "1 image" + } + } + }, + "downloadedImagesCount": "{count,plural,=1{{count}張圖片} other{{count}張圖片}}", + "@downloadedImagesCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "enableTranscoding": "啟用轉碼", + "@enableTranscoding": {}, + "downloadsDeleted": "已刪除。", + "@downloadsDeleted": {}, + "required": "必填", + "@required": {}, + "addDownloads": "添加至下載", + "@addDownloads": {}, + "downloadsAdded": "已添加至下載。", + "@downloadsAdded": {}, + "downloadedSongsWillNotBeDeleted": "已下載的歌曲並不會被刪除", + "@downloadedSongsWillNotBeDeleted": {}, + "customLocation": "自訂位置", + "@customLocation": {}, + "customLocationsBuggy": "現時,自訂位置功能因權限問題而未能完全正常運作。如非必要,建議不要使用。", + "@customLocationsBuggy": {}, + "viewTypeSubtitle": "顯示資訊的方式", + "@viewTypeSubtitle": {}, + "viewType": "顯示模式", + "@viewType": {}, + "grid": "格狀", + "@grid": {}, + "list": "清單", + "@list": {}, + "landscape": "橫向", + "@landscape": {}, + "gridCrossAxisCount": "{value}顯示網格數量", + "@gridCrossAxisCount": { + "description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.", + "placeholders": { + "value": { + "type": "String", + "example": "Portrait" + } + } + }, + "light": "淺色", + "@light": {}, + "addToPlaylistTitle": "加入至播放清單", + "@addToPlaylistTitle": {}, + "noAlbum": "沒有任何專輯", + "@noAlbum": {}, + "noArtist": "沒有任何歌手", + "@noArtist": {}, + "noItem": "沒有任何項目", + "@noItem": {}, + "downloaded": "已下載", + "@downloaded": {}, + "transcode": "轉碼", + "@transcode": {}, + "responseError401": "{error}(代碼:{statusCode})。此錯誤有可能因為用戶名稱/密碼輸入錯誤或您已被登出而導致。", + "@responseError401": { + "placeholders": { + "error": { + "type": "String", + "example": "Unauthorized" + }, + "statusCode": { + "type": "int", + "example": "401" + } + } + }, + "language": "語言", + "@language": {}, + "showUncensoredLogMessage": "紀錄檔內包含你的登入資訊。你是否確認要顯示?", + "@showUncensoredLogMessage": {}, + "confirm": "確定", + "@confirm": {}, + "resetTabs": "重設分頁", + "@resetTabs": {}, + "playNext": "下一首", + "@playNext": { + "description": "Popup menu item title for inserting an item into the play queue after the currently-playing item." + }, + "insertedIntoQueue": "已加入至播放佇列中。", + "@insertedIntoQueue": { + "description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end." + }, + "refresh": "重新載入", + "@refresh": {}, + "noMusicLibrariesBody": "Finamp 未有發現任何音樂媒體庫。請檢查 Jellyfin 伺服器上最少有一個屬於「音樂」類別的媒體庫。", + "@noMusicLibrariesBody": {}, + "noMusicLibrariesTitle": "沒有音樂類媒體庫", + "@noMusicLibrariesTitle": { + "description": "Title for message that shows on the views screen when no music libraries could be found." + }, + "interactions": "互動", + "@interactions": {}, + "deleteDownloadsConfirmButtonText": "刪除", + "@deleteDownloadsConfirmButtonText": { + "description": "Shown in the confirmation dialog for deleting downloaded media from the local device." + }, + "deleteDownloadsPrompt": "你是否確定從裝置中刪除 {itemType, select, album{album} playlist{playlist} artist{artist} genre{genre} track{song} other{}} '{itemName}'?", + "@deleteDownloadsPrompt": { + "placeholders": { + "itemName": { + "type": "String", + "example": "Abandon Ship" + }, + "itemType": { + "type": "String", + "example": "album" + } + }, + "description": "Confirmation prompt shown before deleting downloaded media from the local device, destructive action, doesn't affect the media on the server." + }, + "deleteDownloadsAbortButtonText": "取消", + "@deleteDownloadsAbortButtonText": {}, + "syncDownloadedPlaylists": "同步已下載播放清單", + "@syncDownloadedPlaylists": {}, + "showFastScroller": "顯示快速卷軸", + "@showFastScroller": {}, + "redesignBeta": "測試版", + "@redesignBeta": {}, + "swipeInsertQueueNext": "滑動插播", + "@swipeInsertQueueNext": {}, + "swipeInsertQueueNextSubtitle": "在歌曲列表中輕掃歌曲時,將其插入至播放佇列的最頭而不是最後。", + "@swipeInsertQueueNextSubtitle": {}, + "skipToNext": "下一首歌", + "@skipToNext": {}, + "skipToPrevious": "上一首歌", + "@skipToPrevious": {}, + "download": "下載", + "@download": {}, + "deleteFromDevice": "從裝置中刪除", + "@deleteFromDevice": {}, + "downloadArtist": "下載 {artist}所有的專輯", + "@downloadArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "playArtist": "播放{artist}所有的專輯", + "@playArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "shuffleArtist": "隨機播放{artist}的專輯", + "@shuffleArtist": { + "placeholders": { + "artist": { + "type": "String", + "example": "The Beatles" + } + } + }, + "about": "關於 Finamp", + "@about": {}, + "sync": "與伺服器同步", + "@sync": {} +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..279c454 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,429 @@ +import 'dart:async'; +import 'dart:isolate'; +import 'dart:ui'; + +import 'package:audio_service/audio_service.dart'; +import 'package:audio_session/audio_session.dart'; +import 'package:finamp/color_schemes.g.dart'; +import 'package:finamp/screens/interaction_settings_screen.dart'; +import 'package:finamp/services/finamp_settings_helper.dart'; +import 'package:finamp/services/finamp_user_helper.dart'; +import 'package:finamp/services/offline_listen_helper.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_downloader/flutter_downloader.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:get_it/get_it.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:logging/logging.dart'; +import 'package:uuid/uuid.dart'; + +import 'models/finamp_models.dart'; +import 'models/jellyfin_models.dart'; +import 'models/locale_adapter.dart'; +import 'models/theme_mode_adapter.dart'; +import 'screens/add_download_location_screen.dart'; +import 'screens/add_to_playlist_screen.dart'; +import 'screens/album_screen.dart'; +import 'screens/artist_screen.dart'; +import 'screens/audio_service_settings_screen.dart'; +import 'screens/downloads_error_screen.dart'; +import 'screens/downloads_screen.dart'; +import 'screens/downloads_settings_screen.dart'; +import 'screens/language_selection_screen.dart'; +import 'screens/layout_settings_screen.dart'; +import 'screens/logs_screen.dart'; +import 'screens/music_screen.dart'; +import 'screens/player_screen.dart'; +import 'screens/settings_screen.dart'; +import 'screens/splash_screen.dart'; +import 'screens/tabs_settings_screen.dart'; +import 'screens/transcoding_settings_screen.dart'; +import 'screens/user_selector.dart'; +import 'screens/view_selector.dart'; +import 'services/audio_service_helper.dart'; +import 'services/download_update_stream.dart'; +import 'services/downloads_helper.dart'; +import 'services/jellyfin_api_helper.dart'; +import 'services/locale_helper.dart'; +import 'services/music_player_background_task.dart'; +import 'services/theme_mode_helper.dart'; +import 'setup_logging.dart'; + +void main() async { + // If the app has failed, this is set to true. If true, we don't attempt to run the main app since the error app has started. + bool hasFailed = false; + try { + setupLogging(); + await setupHive(); + _migrateDownloadLocations(); + _migrateSortOptions(); + _setupFinampUserHelper(); + _setupJellyfinApiData(); + _setupOfflineListenLogHelper(); + await _setupDownloader(); + await _setupDownloadsHelper(); + await _setupAudioServiceHelper(); + } catch (e) { + hasFailed = true; + runApp(FinampErrorApp( + error: e, + )); + } + + if (!hasFailed) { + final flutterLogger = Logger("Flutter"); + + FlutterError.onError = (FlutterErrorDetails details) { + FlutterError.presentError(details); + flutterLogger.severe(details.exception, details.exception, details.stack); + }; + // On iOS, the status bar will have black icons by default on the login + // screen as it does not have an AppBar. To fix this, we set the + // brightness to dark manually on startup. + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle(statusBarBrightness: Brightness.dark)); + + runApp(const Finamp()); + } +} + +void _setupJellyfinApiData() { + GetIt.instance.registerSingleton(JellyfinApiHelper()); +} + +void _setupOfflineListenLogHelper() { + GetIt.instance.registerSingleton(OfflineListenLogHelper()); +} + +Future _setupDownloadsHelper() async { + GetIt.instance.registerSingleton(DownloadsHelper()); + final downloadsHelper = GetIt.instance(); + + // We awkwardly cache this value since going from 0.6.14 -> 0.6.16 will switch + // hasCompletedBlurhashImageMigration despite doing a fixed migration + final shouldRunBlurhashImageMigrationIdFix = + FinampSettingsHelper.finampSettings.shouldRunBlurhashImageMigrationIdFix; + + if (!FinampSettingsHelper.finampSettings.hasCompletedBlurhashImageMigration) { + await downloadsHelper.migrateBlurhashImages(); + FinampSettingsHelper.setHasCompletedBlurhashImageMigration(true); + } + + if (shouldRunBlurhashImageMigrationIdFix) { + await downloadsHelper.fixBlurhashMigrationIds(); + FinampSettingsHelper.setHasCompletedBlurhashImageMigrationIdFix(true); + } +} + +Future _setupDownloader() async { + GetIt.instance.registerSingleton(DownloadUpdateStream()); + GetIt.instance().setupSendPort(); + + WidgetsFlutterBinding.ensureInitialized(); + await FlutterDownloader.initialize(debug: true); + + // flutter_downloader sometimes crashes when adding downloads. For some + // reason, adding this callback fixes it. + // https://github.com/fluttercommunity/flutter_downloader/issues/445 + + FlutterDownloader.registerCallback(_DummyCallback.callback); +} + +Future setupHive() async { + await Hive.initFlutter(); + Hive.registerAdapter(BaseItemDtoAdapter()); + Hive.registerAdapter(UserItemDataDtoAdapter()); + Hive.registerAdapter(NameIdPairAdapter()); + Hive.registerAdapter(DownloadedSongAdapter()); + Hive.registerAdapter(DownloadedParentAdapter()); + Hive.registerAdapter(MediaSourceInfoAdapter()); + Hive.registerAdapter(MediaStreamAdapter()); + Hive.registerAdapter(AuthenticationResultAdapter()); + Hive.registerAdapter(FinampUserAdapter()); + Hive.registerAdapter(UserDtoAdapter()); + Hive.registerAdapter(SessionInfoAdapter()); + Hive.registerAdapter(UserConfigurationAdapter()); + Hive.registerAdapter(UserPolicyAdapter()); + Hive.registerAdapter(AccessScheduleAdapter()); + Hive.registerAdapter(PlayerStateInfoAdapter()); + Hive.registerAdapter(SessionUserInfoAdapter()); + Hive.registerAdapter(ClientCapabilitiesAdapter()); + Hive.registerAdapter(DeviceProfileAdapter()); + Hive.registerAdapter(DeviceIdentificationAdapter()); + Hive.registerAdapter(HttpHeaderInfoAdapter()); + Hive.registerAdapter(XmlAttributeAdapter()); + Hive.registerAdapter(DirectPlayProfileAdapter()); + Hive.registerAdapter(TranscodingProfileAdapter()); + Hive.registerAdapter(ContainerProfileAdapter()); + Hive.registerAdapter(ProfileConditionAdapter()); + Hive.registerAdapter(CodecProfileAdapter()); + Hive.registerAdapter(ResponseProfileAdapter()); + Hive.registerAdapter(SubtitleProfileAdapter()); + Hive.registerAdapter(FinampSettingsAdapter()); + Hive.registerAdapter(DownloadLocationAdapter()); + Hive.registerAdapter(ImageBlurHashesAdapter()); + Hive.registerAdapter(BaseItemAdapter()); + Hive.registerAdapter(QueueItemAdapter()); + Hive.registerAdapter(ExternalUrlAdapter()); + Hive.registerAdapter(NameLongIdPairAdapter()); + Hive.registerAdapter(TabContentTypeAdapter()); + Hive.registerAdapter(SortByAdapter()); + Hive.registerAdapter(SortOrderAdapter()); + Hive.registerAdapter(ContentViewTypeAdapter()); + Hive.registerAdapter(DownloadedImageAdapter()); + Hive.registerAdapter(ThemeModeAdapter()); + Hive.registerAdapter(LocaleAdapter()); + Hive.registerAdapter(OfflineListenAdapter()); + await Future.wait([ + Hive.openBox("DownloadedParents"), + Hive.openBox("DownloadedItems"), + Hive.openBox("DownloadIds"), + Hive.openBox("FinampUsers"), + Hive.openBox("CurrentUserId"), + Hive.openBox("FinampSettings"), + Hive.openBox("DownloadedImages"), + Hive.openBox("DownloadedImageIds"), + Hive.openBox("ThemeMode"), + Hive.openBox(LocaleHelper.boxName), + Hive.openBox("OfflineListens") + ]); + + // If the settings box is empty, we add an initial settings value here. + Box finampSettingsBox = Hive.box("FinampSettings"); + if (finampSettingsBox.isEmpty) { + finampSettingsBox.put("FinampSettings", await FinampSettings.create()); + } + + // If no ThemeMode is set, we set it to the default (system) + Box themeModeBox = Hive.box("ThemeMode"); + if (themeModeBox.isEmpty) ThemeModeHelper.setThemeMode(ThemeMode.system); +} + +Future _setupAudioServiceHelper() async { + final session = await AudioSession.instance; + session.configure(const AudioSessionConfiguration.music()); + + final audioHandler = await AudioService.init( + builder: () => MusicPlayerBackgroundTask(), + config: AudioServiceConfig( + androidStopForegroundOnPause: + FinampSettingsHelper.finampSettings.androidStopForegroundOnPause, + androidNotificationChannelName: "Playback", + androidNotificationIcon: "mipmap/white", + androidNotificationChannelId: "com.unicornsonlsd.finamp.audio", + ), + ); + // GetIt.instance.registerSingletonAsync( + // () async => ); + + GetIt.instance.registerSingleton(audioHandler); + GetIt.instance.registerSingleton(AudioServiceHelper()); +} + +/// Migrates the old DownloadLocations list to a map +void _migrateDownloadLocations() { + final finampSettings = FinampSettingsHelper.finampSettings; + + // ignore: deprecated_member_use_from_same_package + if (finampSettings.downloadLocations.isNotEmpty) { + final Map newMap = {}; + + // ignore: deprecated_member_use_from_same_package + for (var element in finampSettings.downloadLocations) { + // Generate a UUID and set the ID field for the DownloadsLocation + final id = const Uuid().v4(); + element.id = id; + newMap[id] = element; + } + + finampSettings.downloadLocationsMap = newMap; + + // ignore: deprecated_member_use_from_same_package + finampSettings.downloadLocations = List.empty(); + + FinampSettingsHelper.overwriteFinampSettings(finampSettings); + } +} + +/// Migrates the old SortBy/SortOrder to a map indexed by tab content type +void _migrateSortOptions() { + final finampSettings = FinampSettingsHelper.finampSettings; + + var changed = false; + + if (finampSettings.tabSortBy.isEmpty) { + for (var type in TabContentType.values) { + // ignore: deprecated_member_use_from_same_package + finampSettings.tabSortBy[type] = finampSettings.sortBy; + } + changed = true; + } + + if (finampSettings.tabSortOrder.isEmpty) { + for (var type in TabContentType.values) { + // ignore: deprecated_member_use_from_same_package + finampSettings.tabSortOrder[type] = finampSettings.sortOrder; + } + changed = true; + } + + if (changed) { + FinampSettingsHelper.overwriteFinampSettings(finampSettings); + } +} + +void _setupFinampUserHelper() { + GetIt.instance.registerSingleton(FinampUserHelper()); +} + +class Finamp extends StatelessWidget { + const Finamp({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ProviderScope( + child: GestureDetector( + onTap: () { + FocusScopeNode currentFocus = FocusScope.of(context); + + if (!currentFocus.hasPrimaryFocus && + currentFocus.focusedChild != null) { + FocusManager.instance.primaryFocus?.unfocus(); + } + }, + // We awkwardly have two ValueListenableBuilders for the locale and + // theme because I didn't want every FinampSettings change to rebuild + // the whole app + child: ValueListenableBuilder( + valueListenable: LocaleHelper.localeListener, + builder: (_, __, ___) { + return ValueListenableBuilder>( + valueListenable: ThemeModeHelper.themeModeListener, + builder: (_, box, __) { + return MaterialApp( + title: "Finamp", + routes: { + SplashScreen.routeName: (context) => const SplashScreen(), + UserSelector.routeName: (context) => const UserSelector(), + ViewSelector.routeName: (context) => const ViewSelector(), + MusicScreen.routeName: (context) => const MusicScreen(), + AlbumScreen.routeName: (context) => const AlbumScreen(), + ArtistScreen.routeName: (context) => const ArtistScreen(), + AddToPlaylistScreen.routeName: (context) => + const AddToPlaylistScreen(), + PlayerScreen.routeName: (context) => const PlayerScreen(), + DownloadsScreen.routeName: (context) => + const DownloadsScreen(), + DownloadsErrorScreen.routeName: (context) => + const DownloadsErrorScreen(), + LogsScreen.routeName: (context) => const LogsScreen(), + SettingsScreen.routeName: (context) => + const SettingsScreen(), + TranscodingSettingsScreen.routeName: (context) => + const TranscodingSettingsScreen(), + DownloadsSettingsScreen.routeName: (context) => + const DownloadsSettingsScreen(), + AddDownloadLocationScreen.routeName: (context) => + const AddDownloadLocationScreen(), + AudioServiceSettingsScreen.routeName: (context) => + const AudioServiceSettingsScreen(), + InteractionSettingsScreen.routeName: (context) => + const InteractionSettingsScreen(), + TabsSettingsScreen.routeName: (context) => + const TabsSettingsScreen(), + LayoutSettingsScreen.routeName: (context) => + const LayoutSettingsScreen(), + LanguageSelectionScreen.routeName: (context) => + const LanguageSelectionScreen(), + }, + initialRoute: SplashScreen.routeName, + theme: ThemeData( + brightness: Brightness.light, + colorScheme: lightColorScheme, + appBarTheme: const AppBarTheme( + systemOverlayStyle: SystemUiOverlayStyle( + statusBarBrightness: Brightness.light, + statusBarIconBrightness: Brightness.dark, + ), + ), + ), + darkTheme: ThemeData( + brightness: Brightness.dark, + colorScheme: darkColorScheme, + ), + themeMode: box.get("ThemeMode"), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + // We awkwardly put English as the first supported locale so + // that basicLocaleListResolution falls back to it instead of + // the first language in supportedLocales (Arabic as of writing) + localeListResolutionCallback: (locales, supportedLocales) => + basicLocaleListResolution(locales, + [const Locale("en")].followedBy(supportedLocales)), + locale: LocaleHelper.locale, + ); + }, + ); + }, + ), + ), + ); + } +} + +class FinampErrorApp extends StatelessWidget { + const FinampErrorApp({Key? key, required this.error}) : super(key: key); + + final dynamic error; + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: "Finamp", + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: ErrorScreen(error: error), + ); + } +} + +class ErrorScreen extends StatelessWidget { + const ErrorScreen({super.key, this.error}); + + final dynamic error; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Text( + AppLocalizations.of(context)!.startupError(error.toString()), + ), + ), + ); + } +} + +class _DummyCallback { + // https://github.com/fluttercommunity/flutter_downloader/issues/629 + @pragma('vm:entry-point') + static void callback(String id, int status, int progress) { + // Add the event to the DownloadUpdateStream instance. + final SendPort? send = + IsolateNameServer.lookupPortByName('downloader_send_port'); + send!.send([id, status, progress]); + } +} diff --git a/lib/models/finamp_models.dart b/lib/models/finamp_models.dart new file mode 100644 index 0000000..3bbaf83 --- /dev/null +++ b/lib/models/finamp_models.dart @@ -0,0 +1,618 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_downloader/flutter_downloader.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hive/hive.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:path/path.dart' as path_helper; +import 'package:uuid/uuid.dart'; + +import '../services/finamp_settings_helper.dart'; +import '../services/get_internal_song_dir.dart'; +import 'jellyfin_models.dart'; + +part 'finamp_models.g.dart'; + +@HiveType(typeId: 8) +class FinampUser { + FinampUser({ + required this.id, + required this.baseUrl, + required this.accessToken, + required this.serverId, + this.currentViewId, + required this.views, + }); + + @HiveField(0) + String id; + @HiveField(1) + String baseUrl; + @HiveField(2) + String accessToken; + @HiveField(3) + String serverId; + @HiveField(4) + String? currentViewId; + @HiveField(5) + Map views; + + BaseItemDto? get currentView => views[currentViewId]; +} + +// These consts are so that we can easily keep the same default for +// FinampSettings's constructor and Hive's defaultValue. +const _songShuffleItemCountDefault = 250; +const _contentViewType = ContentViewType.list; +const _contentGridViewCrossAxisCountPortrait = 2; +const _contentGridViewCrossAxisCountLandscape = 3; +const _showTextOnGridView = true; +const _sleepTimerSeconds = 1800; // 30 Minutes +const _showCoverAsPlayerBackground = true; +const _hideSongArtistsIfSameAsAlbumArtists = true; +const _disableGesture = false; +const _showFastScroller = true; +const _bufferDurationSeconds = 50; +const _tabOrder = TabContentType.values; +const _swipeInsertQueueNext = false; + +@HiveType(typeId: 28) +class FinampSettings { + FinampSettings({ + this.isOffline = false, + this.shouldTranscode = false, + this.transcodeBitrate = 320000, + // downloadLocations is required since the other values can be created with + // default values. create() is used to return a FinampSettings with + // downloadLocations. + required this.downloadLocations, + this.androidStopForegroundOnPause = true, + required this.showTabs, + this.isFavourite = false, + this.sortBy = SortBy.sortName, + this.sortOrder = SortOrder.ascending, + this.songShuffleItemCount = _songShuffleItemCountDefault, + this.contentViewType = _contentViewType, + this.contentGridViewCrossAxisCountPortrait = + _contentGridViewCrossAxisCountPortrait, + this.contentGridViewCrossAxisCountLandscape = + _contentGridViewCrossAxisCountLandscape, + this.showTextOnGridView = _showTextOnGridView, + this.sleepTimerSeconds = _sleepTimerSeconds, + required this.downloadLocationsMap, + this.showCoverAsPlayerBackground = _showCoverAsPlayerBackground, + this.hideSongArtistsIfSameAsAlbumArtists = + _hideSongArtistsIfSameAsAlbumArtists, + this.bufferDurationSeconds = _bufferDurationSeconds, + required this.tabSortBy, + required this.tabSortOrder, + this.tabOrder = _tabOrder, + this.hasCompletedBlurhashImageMigration = true, + this.hasCompletedBlurhashImageMigrationIdFix = true, + this.swipeInsertQueueNext = _swipeInsertQueueNext, + }); + + @HiveField(0) + bool isOffline; + @HiveField(1) + bool shouldTranscode; + @HiveField(2) + int transcodeBitrate; + + @Deprecated("Use downloadedLocationsMap instead") + @HiveField(3) + List downloadLocations; + + @HiveField(4) + bool androidStopForegroundOnPause; + @HiveField(5) + Map showTabs; + + /// Used to remember if the user has set their music screen to favourites + /// mode. + @HiveField(6) + bool isFavourite; + + /// Current sort by setting. + @Deprecated("Use per-tab sort by instead") + @HiveField(7) + SortBy sortBy; + + /// Current sort order setting. + @Deprecated("Use per-tab sort order instead") + @HiveField(8) + SortOrder sortOrder; + + /// Amount of songs to get when shuffling songs. + @HiveField(9, defaultValue: _songShuffleItemCountDefault) + int songShuffleItemCount; + + /// The content view type used by the music screen. + @HiveField(10, defaultValue: _contentViewType) + ContentViewType contentViewType; + + /// Amount of grid tiles to use per-row when portrait. + @HiveField(11, defaultValue: _contentGridViewCrossAxisCountPortrait) + int contentGridViewCrossAxisCountPortrait; + + /// Amount of grid tiles to use per-row when landscape. + @HiveField(12, defaultValue: _contentGridViewCrossAxisCountLandscape) + int contentGridViewCrossAxisCountLandscape; + + /// Whether or not to show the text (title, artist etc) on the grid music + /// screen. + @HiveField(13, defaultValue: _showTextOnGridView) + bool showTextOnGridView = _showTextOnGridView; + + /// The number of seconds to wait in a sleep timer. This is so that the app + /// can remember the last duration. I'd use a Duration type here but Hive + /// doesn't come with an adapter for it by default. + @HiveField(14, defaultValue: _sleepTimerSeconds) + int sleepTimerSeconds; + + @HiveField(15, defaultValue: {}) + Map downloadLocationsMap; + + /// Whether or not to use blurred cover art as background on player screen. + @HiveField(16, defaultValue: _showCoverAsPlayerBackground) + bool showCoverAsPlayerBackground = _showCoverAsPlayerBackground; + + @HiveField(17, defaultValue: _hideSongArtistsIfSameAsAlbumArtists) + bool hideSongArtistsIfSameAsAlbumArtists = + _hideSongArtistsIfSameAsAlbumArtists; + + @HiveField(18, defaultValue: _bufferDurationSeconds) + int bufferDurationSeconds; + + @HiveField(19, defaultValue: _disableGesture) + bool disableGesture = _disableGesture; + + @HiveField(20, defaultValue: {}) + Map tabSortBy; + + @HiveField(21, defaultValue: {}) + Map tabSortOrder; + + @HiveField(22, defaultValue: _tabOrder) + List tabOrder; + + @HiveField(23, defaultValue: false) + bool hasCompletedBlurhashImageMigration; + + @HiveField(24, defaultValue: false) + bool hasCompletedBlurhashImageMigrationIdFix; + + @HiveField(25, defaultValue: _showFastScroller) + bool showFastScroller = _showFastScroller; + + @HiveField(26, defaultValue: _swipeInsertQueueNext) + bool swipeInsertQueueNext; + + static Future create() async { + final internalSongDir = await getInternalSongDir(); + final downloadLocation = DownloadLocation.create( + name: "Internal Storage", + path: internalSongDir.path, + useHumanReadableNames: false, + deletable: false, + ); + return FinampSettings( + downloadLocations: [], + // Create a map of TabContentType from TabContentType's values. + showTabs: Map.fromEntries( + TabContentType.values.map( + (e) => MapEntry(e, true), + ), + ), + downloadLocationsMap: {downloadLocation.id: downloadLocation}, + tabSortBy: {}, + tabSortOrder: {}, + ); + } + + /// Returns the DownloadLocation that is the internal song dir. See the + /// description of the "deletable" property to see how this works. This can + /// technically throw a StateError, but that should never happen™. + DownloadLocation get internalSongDir => + downloadLocationsMap.values.firstWhere((element) => !element.deletable); + + Duration get bufferDuration => Duration(seconds: bufferDurationSeconds); + + set bufferDuration(Duration duration) => + bufferDurationSeconds = duration.inSeconds; + + SortBy getTabSortBy(TabContentType tabType) { + return tabSortBy[tabType] ?? SortBy.sortName; + } + + SortOrder getSortOrder(TabContentType tabType) { + return tabSortOrder[tabType] ?? SortOrder.ascending; + } + + bool get shouldRunBlurhashImageMigrationIdFix => + hasCompletedBlurhashImageMigration && + !hasCompletedBlurhashImageMigrationIdFix; +} + +/// Custom storage locations for storing music. +@HiveType(typeId: 31) +class DownloadLocation { + DownloadLocation( + {required this.name, + required this.path, + required this.useHumanReadableNames, + required this.deletable, + required this.id}); + + /// Human-readable name for the path (shown in settings) + @HiveField(0) + String name; + + /// The path. We store this as a string since it's easier to put into Hive. + @HiveField(1) + String path; + + /// If true, store songs using their actual names instead of Jellyfin item IDs. + @HiveField(2) + bool useHumanReadableNames; + + /// If true, the user can delete this storage location. It's a bit of a hack, + /// but the only undeletable location is the internal storage dir, so we can + /// use this value to get the internal song dir. + @HiveField(3) + bool deletable; + + /// Unique ID for the DownloadLocation. If this DownloadLocation was created + /// before 0.6, it will be "0", very temporarily until it is changed on + /// startup. + @HiveField(4, defaultValue: "0") + String id; + + /// Initialises a new DownloadLocation. id will be a UUID. + static DownloadLocation create({ + required String name, + required String path, + required bool useHumanReadableNames, + required bool deletable, + }) { + return DownloadLocation( + name: name, + path: path, + useHumanReadableNames: useHumanReadableNames, + deletable: deletable, + id: const Uuid().v4(), + ); + } +} + +/// Class used in AddDownloadLocationScreen. Basically just a DownloadLocation +/// with nullable values. Shouldn't be used for actually storing download +/// locations. +class NewDownloadLocation { + NewDownloadLocation({ + this.name, + this.path, + this.useHumanReadableNames, + required this.deletable, + }); + + String? name; + String? path; + bool? useHumanReadableNames; + bool deletable; +} + +/// Supported tab types in MusicScreenTabView. +@HiveType(typeId: 36) +enum TabContentType { + @HiveField(0) + albums, + @HiveField(1) + artists, + @HiveField(2) + playlists, + @HiveField(3) + genres, + @HiveField(4) + songs; + + /// Human-readable version of the [TabContentType]. For example, toString() on + /// [TabContentType.songs], toString() would return "TabContentType.songs". + /// With this function, the same input would return "Songs". + @override + @Deprecated("Use toLocalisedString when possible") + String toString() => _humanReadableName(this); + + String toLocalisedString(BuildContext context) => + _humanReadableLocalisedName(this, context); + + String _humanReadableName(TabContentType tabContentType) { + switch (tabContentType) { + case TabContentType.songs: + return "Songs"; + case TabContentType.albums: + return "Albums"; + case TabContentType.artists: + return "Artists"; + case TabContentType.genres: + return "Genres"; + case TabContentType.playlists: + return "Playlists"; + } + } + + String _humanReadableLocalisedName( + TabContentType tabContentType, BuildContext context) { + switch (tabContentType) { + case TabContentType.songs: + return AppLocalizations.of(context)!.songs; + case TabContentType.albums: + return AppLocalizations.of(context)!.albums; + case TabContentType.artists: + return AppLocalizations.of(context)!.artists; + case TabContentType.genres: + return AppLocalizations.of(context)!.genres; + case TabContentType.playlists: + return AppLocalizations.of(context)!.playlists; + } + } +} + +@HiveType(typeId: 39) +enum ContentViewType { + @HiveField(0) + list, + @HiveField(1) + grid; + + /// Human-readable version of this enum. I've written longer descriptions on + /// enums like [TabContentType], and I can't be bothered to copy and paste it + /// again. + @override + @Deprecated("Use toLocalisedString when possible") + String toString() => _humanReadableName(this); + + String toLocalisedString(BuildContext context) => + _humanReadableLocalisedName(this, context); + + String _humanReadableName(ContentViewType contentViewType) { + switch (contentViewType) { + case ContentViewType.list: + return "List"; + case ContentViewType.grid: + return "Grid"; + } + } + + String _humanReadableLocalisedName( + ContentViewType contentViewType, BuildContext context) { + switch (contentViewType) { + case ContentViewType.list: + return AppLocalizations.of(context)!.list; + case ContentViewType.grid: + return AppLocalizations.of(context)!.grid; + } + } +} + +@HiveType(typeId: 3) +@JsonSerializable( + explicitToJson: true, + anyMap: true, +) +class DownloadedSong { + DownloadedSong({ + required this.song, + required this.mediaSourceInfo, + required this.downloadId, + required this.requiredBy, + required this.path, + required this.useHumanReadableNames, + required this.viewId, + this.isPathRelative = true, + required this.downloadLocationId, + }); + + /// The Jellyfin item for the song + @HiveField(0) + BaseItemDto song; + + /// The media source info for the song (used to get file format) + @HiveField(1) + MediaSourceInfo mediaSourceInfo; + + /// The download ID of the song (for FlutterDownloader) + @HiveField(2) + String downloadId; + + /// The list of parent item IDs the item is downloaded for. If this is 0, the + /// song should be deleted. + @HiveField(3) + List requiredBy; + + /// The path of the song file. if [isPathRelative] is true, this will be a + /// relative path from the song's DownloadLocation. + @HiveField(4) + String path; + + /// Whether or not the file is stored with a human readable name. We need this + /// when deleting downloads, as we need to check for empty folders when + /// deleting files with human readable names. + @HiveField(5) + bool useHumanReadableNames; + + /// The view that this download is in. Used for sorting in offline mode. + @HiveField(6) + String viewId; + + /// Whether or not [path] is relative. + @HiveField(7, defaultValue: false) + bool isPathRelative; + + /// The ID of the DownloadLocation that holds this file. Will be null if made + /// before 0.6. + @HiveField(8) + String? downloadLocationId; + + File get file { + if (isPathRelative) { + final downloadLocation = FinampSettingsHelper + .finampSettings.downloadLocationsMap[downloadLocationId]; + + if (downloadLocation == null) { + throw "DownloadLocation was null in file getter for DownloadsSong!"; + } + + return File(path_helper.join(downloadLocation.path, path)); + } + + return File(path); + } + + DownloadLocation? get downloadLocation => FinampSettingsHelper + .finampSettings.downloadLocationsMap[downloadLocationId]; + + Future get downloadTask async { + final tasks = await FlutterDownloader.loadTasksWithRawQuery( + query: "SELECT * FROM task WHERE task_id = '$downloadId'"); + + if (tasks?.isEmpty == false) { + return tasks!.first; + } + + return null; + } + + factory DownloadedSong.fromJson(Map json) => + _$DownloadedSongFromJson(json); + + Map toJson() => _$DownloadedSongToJson(this); +} + +@HiveType(typeId: 4) +class DownloadedParent { + DownloadedParent({ + required this.item, + required this.downloadedChildren, + required this.viewId, + }); + + @HiveField(0) + BaseItemDto item; + @HiveField(1) + Map downloadedChildren; + + /// The view that this download is in. Used for sorting in offline mode. + @HiveField(2) + String viewId; +} + +@HiveType(typeId: 40) +class DownloadedImage { + DownloadedImage({ + required this.id, + required this.downloadId, + required this.path, + required this.requiredBy, + required this.downloadLocationId, + }); + + /// The image ID + @HiveField(0) + String id; + + /// The download ID of the song (for FlutterDownloader) + @HiveField(1) + String downloadId; + + /// The relative path to the image file. To get the absolute path, use the + /// file getter. + @HiveField(2) + String path; + + /// The list of item IDs that use this image. If this is empty, the image + /// should be deleted. + /// TODO: Investigate adding set support to Hive + @HiveField(3) + List requiredBy; + + /// The ID of the DownloadLocation that holds this file. + @HiveField(4) + String downloadLocationId; + + DownloadLocation? get downloadLocation => FinampSettingsHelper + .finampSettings.downloadLocationsMap[downloadLocationId]; + + File get file { + if (downloadLocation == null) { + throw "Download location is null for image $id, this shouldn't happen..."; + } + + return File(path_helper.join(downloadLocation!.path, path)); + } + + Future get downloadTask async { + final tasks = await FlutterDownloader.loadTasksWithRawQuery( + query: "SELECT * FROM task WHERE task_id = '$downloadId'"); + + if (tasks?.isEmpty == false) { + return tasks!.first; + } + return null; + } + + /// Creates a new DownloadedImage. Does not actually handle downloading or + /// anything. This is only really a thing since having to manually specify + /// empty lists is a bit jank. + static DownloadedImage create({ + required String id, + required String downloadId, + required String path, + List? requiredBy, + required String downloadLocationId, + }) => + DownloadedImage( + id: id, + downloadId: downloadId, + path: path, + requiredBy: requiredBy ?? [], + downloadLocationId: downloadLocationId, + ); +} + +@HiveType(typeId: 43) +class OfflineListen { + OfflineListen({ + required this.timestamp, + required this.userId, + required this.itemId, + required this.name, + this.artist, + this.album, + this.trackMbid, + }); + + /// The stop timestamp of the listen, measured in seconds since the epoch. + @HiveField(0) + int timestamp; + + @HiveField(1) + String userId; + + @HiveField(2) + String itemId; + + @HiveField(3) + String name; + + @HiveField(4) + String? artist; + + @HiveField(5) + String? album; + + // The MusicBrainz ID of the track, if available. + @HiveField(6) + String? trackMbid; +} diff --git a/lib/models/finamp_models.g.dart b/lib/models/finamp_models.g.dart new file mode 100644 index 0000000..14a47d9 --- /dev/null +++ b/lib/models/finamp_models.g.dart @@ -0,0 +1,558 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'finamp_models.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class FinampUserAdapter extends TypeAdapter { + @override + final int typeId = 8; + + @override + FinampUser read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return FinampUser( + id: fields[0] as String, + baseUrl: fields[1] as String, + accessToken: fields[2] as String, + serverId: fields[3] as String, + currentViewId: fields[4] as String?, + views: (fields[5] as Map).cast(), + ); + } + + @override + void write(BinaryWriter writer, FinampUser obj) { + writer + ..writeByte(6) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.baseUrl) + ..writeByte(2) + ..write(obj.accessToken) + ..writeByte(3) + ..write(obj.serverId) + ..writeByte(4) + ..write(obj.currentViewId) + ..writeByte(5) + ..write(obj.views); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FinampUserAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class FinampSettingsAdapter extends TypeAdapter { + @override + final int typeId = 28; + + @override + FinampSettings read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return FinampSettings( + isOffline: fields[0] as bool, + shouldTranscode: fields[1] as bool, + transcodeBitrate: fields[2] as int, + downloadLocations: (fields[3] as List).cast(), + androidStopForegroundOnPause: fields[4] as bool, + showTabs: (fields[5] as Map).cast(), + isFavourite: fields[6] as bool, + sortBy: fields[7] as SortBy, + sortOrder: fields[8] as SortOrder, + songShuffleItemCount: fields[9] == null ? 250 : fields[9] as int, + contentViewType: fields[10] == null + ? ContentViewType.list + : fields[10] as ContentViewType, + contentGridViewCrossAxisCountPortrait: + fields[11] == null ? 2 : fields[11] as int, + contentGridViewCrossAxisCountLandscape: + fields[12] == null ? 3 : fields[12] as int, + showTextOnGridView: fields[13] == null ? true : fields[13] as bool, + sleepTimerSeconds: fields[14] == null ? 1800 : fields[14] as int, + downloadLocationsMap: fields[15] == null + ? {} + : (fields[15] as Map).cast(), + showCoverAsPlayerBackground: + fields[16] == null ? true : fields[16] as bool, + hideSongArtistsIfSameAsAlbumArtists: + fields[17] == null ? true : fields[17] as bool, + bufferDurationSeconds: fields[18] == null ? 50 : fields[18] as int, + tabSortBy: fields[20] == null + ? {} + : (fields[20] as Map).cast(), + tabSortOrder: fields[21] == null + ? {} + : (fields[21] as Map).cast(), + tabOrder: fields[22] == null + ? [ + TabContentType.albums, + TabContentType.artists, + TabContentType.playlists, + TabContentType.genres, + TabContentType.songs + ] + : (fields[22] as List).cast(), + hasCompletedBlurhashImageMigration: + fields[23] == null ? false : fields[23] as bool, + hasCompletedBlurhashImageMigrationIdFix: + fields[24] == null ? false : fields[24] as bool, + swipeInsertQueueNext: fields[26] == null ? false : fields[26] as bool, + ) + ..disableGesture = fields[19] == null ? false : fields[19] as bool + ..showFastScroller = fields[25] == null ? true : fields[25] as bool; + } + + @override + void write(BinaryWriter writer, FinampSettings obj) { + writer + ..writeByte(27) + ..writeByte(0) + ..write(obj.isOffline) + ..writeByte(1) + ..write(obj.shouldTranscode) + ..writeByte(2) + ..write(obj.transcodeBitrate) + ..writeByte(3) + ..write(obj.downloadLocations) + ..writeByte(4) + ..write(obj.androidStopForegroundOnPause) + ..writeByte(5) + ..write(obj.showTabs) + ..writeByte(6) + ..write(obj.isFavourite) + ..writeByte(7) + ..write(obj.sortBy) + ..writeByte(8) + ..write(obj.sortOrder) + ..writeByte(9) + ..write(obj.songShuffleItemCount) + ..writeByte(10) + ..write(obj.contentViewType) + ..writeByte(11) + ..write(obj.contentGridViewCrossAxisCountPortrait) + ..writeByte(12) + ..write(obj.contentGridViewCrossAxisCountLandscape) + ..writeByte(13) + ..write(obj.showTextOnGridView) + ..writeByte(14) + ..write(obj.sleepTimerSeconds) + ..writeByte(15) + ..write(obj.downloadLocationsMap) + ..writeByte(16) + ..write(obj.showCoverAsPlayerBackground) + ..writeByte(17) + ..write(obj.hideSongArtistsIfSameAsAlbumArtists) + ..writeByte(18) + ..write(obj.bufferDurationSeconds) + ..writeByte(19) + ..write(obj.disableGesture) + ..writeByte(20) + ..write(obj.tabSortBy) + ..writeByte(21) + ..write(obj.tabSortOrder) + ..writeByte(22) + ..write(obj.tabOrder) + ..writeByte(23) + ..write(obj.hasCompletedBlurhashImageMigration) + ..writeByte(24) + ..write(obj.hasCompletedBlurhashImageMigrationIdFix) + ..writeByte(25) + ..write(obj.showFastScroller) + ..writeByte(26) + ..write(obj.swipeInsertQueueNext); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FinampSettingsAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class DownloadLocationAdapter extends TypeAdapter { + @override + final int typeId = 31; + + @override + DownloadLocation read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return DownloadLocation( + name: fields[0] as String, + path: fields[1] as String, + useHumanReadableNames: fields[2] as bool, + deletable: fields[3] as bool, + id: fields[4] == null ? '0' : fields[4] as String, + ); + } + + @override + void write(BinaryWriter writer, DownloadLocation obj) { + writer + ..writeByte(5) + ..writeByte(0) + ..write(obj.name) + ..writeByte(1) + ..write(obj.path) + ..writeByte(2) + ..write(obj.useHumanReadableNames) + ..writeByte(3) + ..write(obj.deletable) + ..writeByte(4) + ..write(obj.id); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DownloadLocationAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class DownloadedSongAdapter extends TypeAdapter { + @override + final int typeId = 3; + + @override + DownloadedSong read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return DownloadedSong( + song: fields[0] as BaseItemDto, + mediaSourceInfo: fields[1] as MediaSourceInfo, + downloadId: fields[2] as String, + requiredBy: (fields[3] as List).cast(), + path: fields[4] as String, + useHumanReadableNames: fields[5] as bool, + viewId: fields[6] as String, + isPathRelative: fields[7] == null ? false : fields[7] as bool, + downloadLocationId: fields[8] as String?, + ); + } + + @override + void write(BinaryWriter writer, DownloadedSong obj) { + writer + ..writeByte(9) + ..writeByte(0) + ..write(obj.song) + ..writeByte(1) + ..write(obj.mediaSourceInfo) + ..writeByte(2) + ..write(obj.downloadId) + ..writeByte(3) + ..write(obj.requiredBy) + ..writeByte(4) + ..write(obj.path) + ..writeByte(5) + ..write(obj.useHumanReadableNames) + ..writeByte(6) + ..write(obj.viewId) + ..writeByte(7) + ..write(obj.isPathRelative) + ..writeByte(8) + ..write(obj.downloadLocationId); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DownloadedSongAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class DownloadedParentAdapter extends TypeAdapter { + @override + final int typeId = 4; + + @override + DownloadedParent read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return DownloadedParent( + item: fields[0] as BaseItemDto, + downloadedChildren: (fields[1] as Map).cast(), + viewId: fields[2] as String, + ); + } + + @override + void write(BinaryWriter writer, DownloadedParent obj) { + writer + ..writeByte(3) + ..writeByte(0) + ..write(obj.item) + ..writeByte(1) + ..write(obj.downloadedChildren) + ..writeByte(2) + ..write(obj.viewId); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DownloadedParentAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class DownloadedImageAdapter extends TypeAdapter { + @override + final int typeId = 40; + + @override + DownloadedImage read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return DownloadedImage( + id: fields[0] as String, + downloadId: fields[1] as String, + path: fields[2] as String, + requiredBy: (fields[3] as List).cast(), + downloadLocationId: fields[4] as String, + ); + } + + @override + void write(BinaryWriter writer, DownloadedImage obj) { + writer + ..writeByte(5) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.downloadId) + ..writeByte(2) + ..write(obj.path) + ..writeByte(3) + ..write(obj.requiredBy) + ..writeByte(4) + ..write(obj.downloadLocationId); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DownloadedImageAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class OfflineListenAdapter extends TypeAdapter { + @override + final int typeId = 43; + + @override + OfflineListen read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return OfflineListen( + timestamp: fields[0] as int, + userId: fields[1] as String, + itemId: fields[2] as String, + name: fields[3] as String, + artist: fields[4] as String?, + album: fields[5] as String?, + trackMbid: fields[6] as String?, + ); + } + + @override + void write(BinaryWriter writer, OfflineListen obj) { + writer + ..writeByte(7) + ..writeByte(0) + ..write(obj.timestamp) + ..writeByte(1) + ..write(obj.userId) + ..writeByte(2) + ..write(obj.itemId) + ..writeByte(3) + ..write(obj.name) + ..writeByte(4) + ..write(obj.artist) + ..writeByte(5) + ..write(obj.album) + ..writeByte(6) + ..write(obj.trackMbid); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is OfflineListenAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class TabContentTypeAdapter extends TypeAdapter { + @override + final int typeId = 36; + + @override + TabContentType read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return TabContentType.albums; + case 1: + return TabContentType.artists; + case 2: + return TabContentType.playlists; + case 3: + return TabContentType.genres; + case 4: + return TabContentType.songs; + default: + return TabContentType.albums; + } + } + + @override + void write(BinaryWriter writer, TabContentType obj) { + switch (obj) { + case TabContentType.albums: + writer.writeByte(0); + break; + case TabContentType.artists: + writer.writeByte(1); + break; + case TabContentType.playlists: + writer.writeByte(2); + break; + case TabContentType.genres: + writer.writeByte(3); + break; + case TabContentType.songs: + writer.writeByte(4); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TabContentTypeAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class ContentViewTypeAdapter extends TypeAdapter { + @override + final int typeId = 39; + + @override + ContentViewType read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return ContentViewType.list; + case 1: + return ContentViewType.grid; + default: + return ContentViewType.list; + } + } + + @override + void write(BinaryWriter writer, ContentViewType obj) { + switch (obj) { + case ContentViewType.list: + writer.writeByte(0); + break; + case ContentViewType.grid: + writer.writeByte(1); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ContentViewTypeAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +DownloadedSong _$DownloadedSongFromJson(Map json) => DownloadedSong( + song: + BaseItemDto.fromJson(Map.from(json['song'] as Map)), + mediaSourceInfo: MediaSourceInfo.fromJson( + Map.from(json['mediaSourceInfo'] as Map)), + downloadId: json['downloadId'] as String, + requiredBy: (json['requiredBy'] as List) + .map((e) => e as String) + .toList(), + path: json['path'] as String, + useHumanReadableNames: json['useHumanReadableNames'] as bool, + viewId: json['viewId'] as String, + isPathRelative: json['isPathRelative'] as bool? ?? true, + downloadLocationId: json['downloadLocationId'] as String?, + ); + +Map _$DownloadedSongToJson(DownloadedSong instance) => + { + 'song': instance.song.toJson(), + 'mediaSourceInfo': instance.mediaSourceInfo.toJson(), + 'downloadId': instance.downloadId, + 'requiredBy': instance.requiredBy, + 'path': instance.path, + 'useHumanReadableNames': instance.useHumanReadableNames, + 'viewId': instance.viewId, + 'isPathRelative': instance.isPathRelative, + 'downloadLocationId': instance.downloadLocationId, + }; diff --git a/lib/models/jellyfin_models.dart b/lib/models/jellyfin_models.dart new file mode 100644 index 0000000..ad74b0a --- /dev/null +++ b/lib/models/jellyfin_models.dart @@ -0,0 +1,3582 @@ +/// This file contains classes returned by Jellyfin. Some values may not be +/// exactly right as there may not be an easy way to convert the values into +/// Dart objects. In most cases, these values will be strings. Enums are also +/// represented as strings. This could probably be changed, but it would cause +/// compatibility problems. Value descriptions are copied directly from +/// Jellyfin's API documentation (https://api.jellyfin.org) +/// +/// These classes should be correct with Jellyfin 10.7.5 + +import 'package:finamp/models/finamp_models.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hive/hive.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'jellyfin_models.g.dart'; + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 9) +class UserDto { + UserDto({ + this.name, + this.serverId, + this.serverName, + required this.id, + this.primaryImageTag, + required this.hasPassword, + required this.hasConfiguredPassword, + required this.hasConfiguredEasyPassword, + this.enableAutoLogin, + this.lastLoginDate, + this.lastActivityDate, + this.configuration, + this.policy, + this.primaryImageAspectRatio, + }); + + /// Gets or sets the name. + @HiveField(0) + String? name; + + /// Gets or sets the server identifier. + @HiveField(1) + String? serverId; + + /// Gets or sets the name of the server. This is not used by the server and is + /// for client-side usage only. + @HiveField(2) + String? serverName; + + /// Gets or sets the id. + @HiveField(3) + String id; + + /// Gets or sets the primary image tag. + @HiveField(4) + String? primaryImageTag; + + /// Gets or sets a value indicating whether this instance has password. + @HiveField(5) + bool hasPassword; + + /// Gets or sets a value indicating whether this instance has configured + /// password. + @HiveField(6) + bool hasConfiguredPassword; + + /// Gets or sets a value indicating whether this instance has configured easy + /// password. + @HiveField(7) + bool hasConfiguredEasyPassword; + + /// Gets or sets whether async login is enabled or not. + @HiveField(8) + bool? enableAutoLogin; + + /// Gets or sets the last login date. + @HiveField(9) + String? lastLoginDate; + + /// Gets or sets the last activity date. + @HiveField(10) + String? lastActivityDate; + + /// Gets or sets the configuration. + @HiveField(11) + UserConfiguration? configuration; + + /// Gets or sets the policy. + @HiveField(12) + UserPolicy? policy; + + /// Gets or sets the primary image aspect ratio. + @HiveField(13) + double? primaryImageAspectRatio; + + factory UserDto.fromJson(Map json) => + _$UserDtoFromJson(json); + Map toJson() => _$UserDtoToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 11) +class UserConfiguration { + UserConfiguration({ + this.audioLanguagePreference, + required this.playDefaultAudioTrack, + this.subtitleLanguagePreference, + required this.displayMissingEpisodes, + this.groupedFolders, + required this.subtitleMode, + required this.displayCollectionsView, + required this.enableLocalPassword, + this.orderedViews, + this.latestItemsExcludes, + this.myMediaExcludes, + required this.hidePlayedInLatest, + required this.rememberAudioSelections, + required this.rememberSubtitleSelections, + required this.enableNextEpisodeAutoPlay, + }); + + /// Gets or sets the audio language preference. + @HiveField(0) + String? audioLanguagePreference; + + /// Gets or sets a value indicating whether [play default audio track]. + @HiveField(1) + bool playDefaultAudioTrack; + + /// Gets or sets the subtitle language preference. + @HiveField(2) + String? subtitleLanguagePreference; + + @HiveField(3) + bool displayMissingEpisodes; + + @HiveField(4) + List? groupedFolders; + + /// Enum: "Default" "Always" "OnlyForced" "None" "Smart" An enum representing + /// a subtitle playback mode. + @HiveField(5) + String subtitleMode; + + @HiveField(6) + bool displayCollectionsView; + + @HiveField(7) + bool enableLocalPassword; + + @HiveField(8) + List? orderedViews; + + @HiveField(9) + List? latestItemsExcludes; + + @HiveField(10) + List? myMediaExcludes; + + @HiveField(11) + bool hidePlayedInLatest; + + @HiveField(12) + bool rememberAudioSelections; + + @HiveField(13) + bool rememberSubtitleSelections; + + @HiveField(14) + bool enableNextEpisodeAutoPlay; + + factory UserConfiguration.fromJson(Map json) => + _$UserConfigurationFromJson(json); + Map toJson() => _$UserConfigurationToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 12) +class UserPolicy { + UserPolicy({ + required this.isAdministrator, + required this.isHidden, + required this.isDisabled, + this.maxParentalRating, + this.blockedTags, + required this.enableUserPreferenceAccess, + this.accessSchedules, + this.blockUnratedItems, + required this.enableRemoteControlOfOtherUsers, + required this.enableSharedDeviceControl, + required this.enableRemoteAccess, + required this.enableLiveTvManagement, + required this.enableLiveTvAccess, + required this.enableMediaPlayback, + required this.enableAudioPlaybackTranscoding, + required this.enableVideoPlaybackTranscoding, + required this.enablePlaybackRemuxing, + required this.forceRemoteSourceTranscoding, + required this.enableContentDeletion, + required this.enableContentDeletionFromFolders, + required this.enableContentDownloading, + required this.enableSyncTranscoding, + required this.enableMediaConversion, + this.enabledDevices, + required this.enableAllDevices, + this.enabledChannels, + required this.enableAllChannels, + this.enabledFolders, + required this.enableAllFolders, + required this.invalidLoginAttemptCount, + required this.loginAttemptsBeforeLockout, + required this.maxActiveSessions, + required this.enablePublicSharing, + this.blockedMediaFolders, + this.blockedChannels, + required this.remoteClientBitrateLimit, + this.authenticationProviderId, + this.passwordResetProviderId, + required this.syncPlayAccess, + }); + + /// Gets or sets a value indicating whether this instance is administrator. + @HiveField(0) + bool isAdministrator; + + /// Gets or sets a value indicating whether this instance is hidden. + @HiveField(1) + bool isHidden; + + /// Gets or sets a value indicating whether this instance is disabled. + @HiveField(2) + bool isDisabled; + + /// Gets or sets the max parental rating. + @HiveField(3) + int? maxParentalRating; + + @HiveField(4) + List? blockedTags; + + @HiveField(5) + bool enableUserPreferenceAccess; + + @HiveField(6) + List? accessSchedules; + + /// Items Enum: "Movie" "Trailer" "Series" "Music" "Book" "LiveTvChannel" + /// "LiveTvProgram" "ChannelContent" "Other" + @HiveField(7) + List? blockUnratedItems; + + @HiveField(8) + bool enableRemoteControlOfOtherUsers; + + @HiveField(9) + bool enableSharedDeviceControl; + + @HiveField(10) + bool enableRemoteAccess; + + @HiveField(11) + bool enableLiveTvManagement; + + @HiveField(12) + bool enableLiveTvAccess; + + @HiveField(13) + bool enableMediaPlayback; + + @HiveField(14) + bool enableAudioPlaybackTranscoding; + + @HiveField(15) + bool enableVideoPlaybackTranscoding; + + @HiveField(16) + bool enablePlaybackRemuxing; + + @HiveField(17) + bool enableContentDeletion; + + @HiveField(18) + List? enableContentDeletionFromFolders; + + @HiveField(19) + bool enableContentDownloading; + + /// Gets or sets a value indicating whether [enable synchronize]. + @HiveField(20) + bool enableSyncTranscoding; + + @HiveField(21) + bool enableMediaConversion; + + @HiveField(22) + List? enabledDevices; + + @HiveField(23) + bool enableAllDevices; + + @HiveField(24) + List? enabledChannels; + + @HiveField(25) + bool enableAllChannels; + + @HiveField(26) + List? enabledFolders; + + @HiveField(27) + bool enableAllFolders; + + @HiveField(28) + int invalidLoginAttemptCount; + + @HiveField(29) + bool enablePublicSharing; + + @HiveField(30) + List? blockedMediaFolders; + + @HiveField(31) + List? blockedChannels; + + @HiveField(32) + int remoteClientBitrateLimit; + + @HiveField(33) + String? authenticationProviderId; + + // Below fields were added during null safety migration (0.5.0) + + @HiveField(34) + bool? forceRemoteSourceTranscoding; + + @HiveField(35) + int? loginAttemptsBeforeLockout; + + @HiveField(36) + int? maxActiveSessions; + + @HiveField(37) + String? passwordResetProviderId; + + /// Enum: "CreateAndJoinGroups" "JoinGroups" "None" + /// Enum SyncPlayUserAccessType. + @HiveField(38) + String syncPlayAccess; + + factory UserPolicy.fromJson(Map json) => + _$UserPolicyFromJson(json); + Map toJson() => _$UserPolicyToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 13) +class AccessSchedule { + AccessSchedule({ + required this.id, + required this.userId, + required this.dayOfWeek, + required this.startHour, + required this.endHour, + }); + + /// Enum: "Sunday" "Monday" "Tuesday" "Wednesday" "Thursday" "Friday" + /// "Saturday" "Everyday" "Weekday" "Weekend" Gets or sets the day of week. + @HiveField(0) + String dayOfWeek; + + /// Gets or sets the start hour. + @HiveField(1) + double startHour; + + /// Gets or sets the end hour. + @HiveField(2) + double endHour; + + // Below fields were added during null safety migration (0.5.0) + + /// Gets or sets the id of this instance. + @HiveField(3) + int id; + + /// Gets or sets the id of the associated user. + @HiveField(4) + String userId; + + factory AccessSchedule.fromJson(Map json) => + _$AccessScheduleFromJson(json); + Map toJson() => _$AccessScheduleToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 7) +class AuthenticationResult { + AuthenticationResult({ + this.user, + this.sessionInfo, + this.accessToken, + this.serverId, + }); + + @HiveField(0) + UserDto? user; + + @HiveField(1) + SessionInfo? sessionInfo; + + @HiveField(2) + String? accessToken; + + @HiveField(3) + String? serverId; + + factory AuthenticationResult.fromJson(Map json) => + _$AuthenticationResultFromJson(json); + Map toJson() => _$AuthenticationResultToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 10) +class SessionInfo { + SessionInfo({ + this.playState, + this.additionalUsers, + this.capabilities, + this.remoteEndPoint, + this.playableMediaTypes, + this.playlistItemId, + this.id, + this.serverId, + required this.userId, + this.userName, + this.userPrimaryImageTag, + this.client, + required this.lastActivityDate, + this.deviceName, + this.deviceType, + this.nowPlayingItem, + this.deviceId, + this.supportedCommands, + this.transcodingInfo, + required this.supportsRemoteControl, + required this.lastPlaybackCheckIn, + this.fullNowPlayingItem, + this.nowViewingItem, + this.applicationVersion, + required this.isActive, + required this.supportsMediaControl, + this.nowPlayingQueue, + required this.hasCustomDeviceName, + }); + + @HiveField(0) + PlayerStateInfo? playState; + + @HiveField(1) + List? additionalUsers; + + @HiveField(2) + ClientCapabilities? capabilities; + + /// Gets or sets the remote end point. + @HiveField(3) + String? remoteEndPoint; + + /// Gets or sets the playable media types. + @HiveField(4) + List? playableMediaTypes; + + @HiveField(5) + String? playlistItemId; + + /// Gets or sets the id. + @HiveField(6) + String? id; + + @HiveField(7) + String? serverId; + + /// Gets or sets the user id. + @HiveField(8) + String userId; + + /// Gets or sets the username. + @HiveField(9) + String? userName; + + @HiveField(10) + String? userPrimaryImageTag; + + /// Gets or sets the type of the client. + @HiveField(11) + String? client; + + /// Gets or sets the last activity date. + @HiveField(12) + String lastActivityDate; + + /// Gets or sets the name of the device. + @HiveField(13) + String? deviceName; + + /// Gets or sets the type of the device. + @HiveField(14) + String? deviceType; + + /// Gets or sets the now playing item. + @HiveField(15) + BaseItemDto? nowPlayingItem; + + /// Gets or sets the device id. + @HiveField(16) + String? deviceId; + + /// Items Enum: "MoveUp" "MoveDown" "MoveLeft" "MoveRight" "PageUp" "PageDown" + /// "PreviousLetter" "NextLetter" "ToggleOsd" "ToggleContextMenu" "Select" + /// "Back" "TakeScreenshot" "SendKey" "SendString" "GoHome" "GoToSettings" + /// "VolumeUp" "VolumeDown" "Mute" "Unmute" "ToggleMute" "SetVolume" + /// "SetAudioStreamIndex" "SetSubtitleStreamIndex" "ToggleFullscreen" + /// "DisplayContent" "GoToSearch" "DisplayMessage" "SetRepeatMode" "ChannelUp" + /// "ChannelDown" "Guide" "ToggleStats" "PlayMediaSource" "PlayTrailers" + /// "SetShuffleQueue" "PlayState" "PlayNext" "ToggleOsdMenu" "Play" Gets or + /// sets the supported commands. + @HiveField(17) + List? supportedCommands; + + @HiveField(18) + TranscodingInfo? transcodingInfo; + + @HiveField(19) + bool supportsRemoteControl; + + // Below fields were added during null safety migration (0.5.0) + + /// Gets or sets the last playback check in. + @HiveField(20) + String? lastPlaybackCheckIn; + + @HiveField(21) + BaseItem? fullNowPlayingItem; + + @HiveField(22) + BaseItemDto? nowViewingItem; + + /// Gets or sets the application version. + @HiveField(23) + String? applicationVersion; + + /// Gets a value indicating whether this instance is active. + @HiveField(24) + bool isActive; + + @HiveField(25) + bool supportsMediaControl; + + @HiveField(26) + List? nowPlayingQueue; + + @HiveField(27) + bool hasCustomDeviceName; + + factory SessionInfo.fromJson(Map json) => + _$SessionInfoFromJson(json); + Map toJson() => _$SessionInfoToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +class TranscodingInfo { + TranscodingInfo({ + this.audioCodec, + this.videoCodec, + this.container, + required this.isVideoDirect, + required this.isAudioDirect, + this.bitrate, + this.framerate, + this.completionPercentage, + this.width, + this.height, + this.audioChannels, + this.transcodeReasons, + }); + + String? audioCodec; + + String? videoCodec; + + String? container; + + bool isVideoDirect; + + bool isAudioDirect; + + int? bitrate; + + double? framerate; + + double? completionPercentage; + + int? width; + + int? height; + + int? audioChannels; + + /// Items Enum: "ContainerNotSupported" "VideoCodecNotSupported" + /// "AudioCodecNotSupported" "ContainerBitrateExceedsLimit" + /// "AudioBitrateNotSupported" "AudioChannelsNotSupported" + /// "VideoResolutionNotSupported" "UnknownVideoStreamInfo" + /// "UnknownAudioStreamInfo" "AudioProfileNotSupported" + /// "AudioSampleRateNotSupported" "AnamorphicVideoNotSupported" + /// "InterlacedVideoNotSupported" "SecondaryAudioNotSupported" + /// "RefFramesNotSupported" "VideoBitDepthNotSupported" + /// "VideoBitrateNotSupported" "VideoFramerateNotSupported" + /// "VideoLevelNotSupported" "VideoProfileNotSupported" + /// "AudioBitDepthNotSupported" "SubtitleCodecNotSupported" "DirectPlayError" + List? transcodeReasons; + + factory TranscodingInfo.fromJson(Map json) => + _$TranscodingInfoFromJson(json); + Map toJson() => _$TranscodingInfoToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 14) +class PlayerStateInfo { + PlayerStateInfo({ + this.positionTicks, + required this.canSeek, + required this.isPaused, + required this.isMuted, + this.volumeLevel, + this.audioStreamIndex, + this.subtitleStreamIndex, + this.mediaSourceId, + this.playMethod, + this.repeatMode, + }); + + /// Gets or sets the now playing position ticks. + @HiveField(0) + int? positionTicks; + + /// Gets or sets a value indicating whether this instance can seek. + @HiveField(1) + bool canSeek; + + /// Gets or sets a value indicating whether this instance is paused. + @HiveField(2) + bool isPaused; + + /// Gets or sets a value indicating whether this instance is muted. + @HiveField(3) + bool isMuted; + + /// Gets or sets the volume level. + @HiveField(4) + int? volumeLevel; + + /// Gets or sets the index of the now playing audio stream. + @HiveField(5) + int? audioStreamIndex; + + /// Gets or sets the index of the now playing subtitle stream. + @HiveField(6) + int? subtitleStreamIndex; + + /// Gets or sets the now playing media version identifier. + @HiveField(7) + String? mediaSourceId; + + /// Enum: "Transcode" "DirectStream" "DirectPlay" Gets or sets the play + /// method. + @HiveField(8) + String? playMethod; + + /// Enum: "RepeatNone" "RepeatAll" "RepeatOne" Gets or sets the repeat mode. + @HiveField(9) + String? repeatMode; + + factory PlayerStateInfo.fromJson(Map json) => + _$PlayerStateInfoFromJson(json); + Map toJson() => _$PlayerStateInfoToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 15) +class SessionUserInfo { + SessionUserInfo({ + required this.userId, + this.userName, + }); + + /// Gets or sets the user identifier. + @HiveField(0) + String userId; + + /// Gets or sets the name of the user. + @HiveField(1) + String? userName; + + factory SessionUserInfo.fromJson(Map json) => + _$SessionUserInfoFromJson(json); + Map toJson() => _$SessionUserInfoToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 16) +class ClientCapabilities { + ClientCapabilities({ + this.playableMediaTypes, + this.supportedCommands, + required this.supportsMediaControl, + required this.supportsPersistentIdentifier, + required this.supportsSync, + this.deviceProfile, + this.iconUrl, + required this.supportsContentUploading, + this.messageCallbackUrl, + this.appStoreUrl, + }); + + @HiveField(0) + List? playableMediaTypes; + + /// Items Enum: "MoveUp" "MoveDown" "MoveLeft" "MoveRight" "PageUp" "PageDown" + /// "PreviousLetter" "NextLetter" "ToggleOsd" "ToggleContextMenu" "Select" + /// "Back" "TakeScreenshot" "SendKey" "SendString" "GoHome" "GoToSettings" + /// "VolumeUp" "VolumeDown" "Mute" "Unmute" "ToggleMute" "SetVolume" + /// "SetAudioStreamIndex" "SetSubtitleStreamIndex" "ToggleFullscreen" + /// "DisplayContent" "GoToSearch" "DisplayMessage" "SetRepeatMode" "ChannelUp" + /// "ChannelDown" "Guide" "ToggleStats" "PlayMediaSource" "PlayTrailers" + /// "SetShuffleQueue" "PlayState" "PlayNext" "ToggleOsdMenu" "Play" + @HiveField(1) + List? supportedCommands; + + @HiveField(2) + bool? supportsMediaControl; + + @HiveField(3) + bool? supportsPersistentIdentifier; + + @HiveField(4) + bool? supportsSync; + + /// Defines the MediaBrowser.Model.Dlna.DeviceProfile. + @HiveField(5) + DeviceProfile? deviceProfile; + + @HiveField(6) + String? iconUrl; + + // Below fields were added during null safety migration (0.5.0) + + @HiveField(7) + bool? supportsContentUploading; + + @HiveField(8) + String? messageCallbackUrl; + + @HiveField(9) + String? appStoreUrl; + + factory ClientCapabilities.fromJson(Map json) => + _$ClientCapabilitiesFromJson(json); + Map toJson() => _$ClientCapabilitiesToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 17) +class DeviceProfile { + DeviceProfile({ + this.name, + this.id, + this.identification, + this.friendlyName, + this.manufacturer, + this.manufacturerUrl, + this.modelName, + this.modelDescription, + this.modelNumber, + this.modelUrl, + this.serialNumber, + required this.enableAlbumArtInDidl, + required this.enableSingleAlbumArtLimit, + required this.enableSingleSubtitleLimit, + this.supportedMediaTypes, + this.userId, + this.albumArtPn, + required this.maxAlbumArtWidth, + required this.maxAlbumArtHeight, + this.maxIconWidth, + this.maxIconHeight, + this.maxStreamingBitrate, + this.maxStaticBitrate, + this.musicStreamingTranscodingBitrate, + this.maxStaticMusicBitrate, + this.sonyAggregationFlags, + this.protocolInfo, + required this.timelineOffsetSeconds, + required this.requiresPlainVideoItems, + required this.requiresPlainFolders, + required this.enableMSMediaReceiverRegistrar, + required this.ignoreTranscodeByteRangeRequests, + this.xmlRootAttributes, + this.directPlayProfiles, + this.transcodingProfiles, + this.containerProfiles, + this.codecProfiles, + this.responseProfiles, + this.subtitleProfiles, + }); + + /// Gets or sets the Name. + @HiveField(0) + String? name; + + /// Gets or sets the Id. + @HiveField(1) + String? id; + + /// Gets or sets the Identification. + @HiveField(2) + DeviceIdentification? identification; + + /// Gets or sets the FriendlyName. + @HiveField(3) + String? friendlyName; + + /// Gets or sets the Manufacturer. + @HiveField(4) + String? manufacturer; + + /// Gets or sets the ManufacturerUrl. + @HiveField(5) + String? manufacturerUrl; + + /// Gets or sets the ModelName. + @HiveField(6) + String? modelName; + + /// Gets or sets the ModelDescription. + @HiveField(7) + String? modelDescription; + + /// Gets or sets the ModelNumber. + @HiveField(8) + String? modelNumber; + + /// Gets or sets the ModelUrl. + @HiveField(9) + String? modelUrl; + + /// Gets or sets the SerialNumber. + @HiveField(10) + String? serialNumber; + + /// Gets or sets a value indicating whether EnableAlbumArtInDidl. + @HiveField(11) + bool enableAlbumArtInDidl; + + /// Gets or sets a value indicating whether EnableSingleAlbumArtLimit. + @HiveField(12) + bool enableSingleAlbumArtLimit; + + /// Gets or sets a value indicating whether EnableSingleSubtitleLimit. + @HiveField(13) + bool enableSingleSubtitleLimit; + + /// Gets or sets the SupportedMediaTypes. + @HiveField(14) + String? supportedMediaTypes; + + /// Gets or sets the UserId. + @HiveField(15) + String? userId; + + /// Gets or sets the AlbumArtPn. + @HiveField(16) + String? albumArtPn; + + /// Gets or sets the MaxAlbumArtWidth. + @HiveField(17) + int maxAlbumArtWidth; + + /// Gets or sets the MaxAlbumArtHeight. + @HiveField(18) + int maxAlbumArtHeight; + + /// Gets or sets the MaxIconWidth. + @HiveField(19) + int? maxIconWidth; + + /// Gets or sets the MaxIconHeight. + @HiveField(20) + int? maxIconHeight; + + /// Gets or sets the MaxStreamingBitrate. + @HiveField(21) + int? maxStreamingBitrate; + + /// Gets or sets the MaxStaticBitrate. + @HiveField(22) + int? maxStaticBitrate; + + /// Gets or sets the MusicStreamingTranscodingBitrate. + @HiveField(23) + int? musicStreamingTranscodingBitrate; + + /// Gets or sets the MaxStaticMusicBitrate. + @HiveField(24) + int? maxStaticMusicBitrate; + + /// Gets or sets the content of the aggregationFlags element in the + /// urn:schemas-sonycom:av namespace. + @HiveField(25) + String? sonyAggregationFlags; + + /// Gets or sets the ProtocolInfo. + @HiveField(26) + String? protocolInfo; + + /// Gets or sets the TimelineOffsetSeconds. + @HiveField(27) + int timelineOffsetSeconds; + + /// Gets or sets a value indicating whether RequiresPlainVideoItems. + @HiveField(28) + bool requiresPlainVideoItems; + + /// Gets or sets a value indicating whether RequiresPlainFolders. + @HiveField(29) + bool requiresPlainFolders; + + /// Gets or sets a value indicating whether EnableMSMediaReceiverRegistrar. + @HiveField(30) + bool enableMSMediaReceiverRegistrar; + + /// Gets or sets a value indicating whether IgnoreTranscodeByteRangeRequests. + @HiveField(31) + bool ignoreTranscodeByteRangeRequests; + + /// Gets or sets the XmlRootAttributes. + @HiveField(32) + List? xmlRootAttributes; + + /// Gets or sets the direct play profiles. + @HiveField(33) + List? directPlayProfiles; + + /// Gets or sets the transcoding profiles. + @HiveField(34) + List? transcodingProfiles; + + /// Gets or sets the ContainerProfiles. + @HiveField(35) + List? containerProfiles; + + /// Gets or sets the CodecProfiles. + @HiveField(36) + List? codecProfiles; + + /// Gets or sets the ResponseProfiles. + @HiveField(37) + List? responseProfiles; + + /// Gets or sets the SubtitleProfiles. + @HiveField(38) + List? subtitleProfiles; + + factory DeviceProfile.fromJson(Map json) => + _$DeviceProfileFromJson(json); + Map toJson() => _$DeviceProfileToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 18) +class DeviceIdentification { + DeviceIdentification({ + this.friendlyName, + this.modelNumber, + this.serialNumber, + this.modelName, + this.modelDescription, + this.modelUrl, + this.manufacturer, + this.manufacturerUrl, + this.headers, + }); + + /// Gets or sets the name of the friendly. + @HiveField(0) + String? friendlyName; + + /// Gets or sets the model number. + @HiveField(1) + String? modelNumber; + + /// Gets or sets the serial number. + @HiveField(2) + String? serialNumber; + + /// Gets or sets the name of the model. + @HiveField(3) + String? modelName; + + /// Gets or sets the model description. + @HiveField(4) + String? modelDescription; + + /// Gets or sets the model URL. + @HiveField(5) + String? modelUrl; + + /// Gets or sets the manufacturer. + @HiveField(6) + String? manufacturer; + + /// Gets or sets the manufacturer URL. + @HiveField(7) + String? manufacturerUrl; + + /// Gets or sets the headers. + @HiveField(8) + List? headers; + + factory DeviceIdentification.fromJson(Map json) => + _$DeviceIdentificationFromJson(json); + Map toJson() => _$DeviceIdentificationToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 19) +class HttpHeaderInfo { + HttpHeaderInfo({ + this.name, + this.value, + required this.match, + }); + + @HiveField(0) + String? name; + + @HiveField(1) + String? value; + + /// Enum: "Equals" "Regex" "Substring" + @HiveField(2) + String match; + + factory HttpHeaderInfo.fromJson(Map json) => + _$HttpHeaderInfoFromJson(json); + Map toJson() => _$HttpHeaderInfoToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 20) +class XmlAttribute { + XmlAttribute({ + this.name, + this.value, + }); + + @HiveField(0) + String? name; + + @HiveField(1) + String? value; + + factory XmlAttribute.fromJson(Map json) => + _$XmlAttributeFromJson(json); + Map toJson() => _$XmlAttributeToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 21) +class DirectPlayProfile { + DirectPlayProfile({ + this.container, + this.audioCodec, + this.videoCodec, + required this.type, + }); + + @HiveField(0) + String? container; + + @HiveField(1) + String? audioCodec; + + @HiveField(2) + String? videoCodec; + + /// Enum: "Audio" "Video" "Photo" + @HiveField(3) + String type; + + factory DirectPlayProfile.fromJson(Map json) => + _$DirectPlayProfileFromJson(json); + Map toJson() => _$DirectPlayProfileToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 22) +class TranscodingProfile { + TranscodingProfile({ + this.container, + required this.type, + this.videoCodec, + this.audioCodec, + this.protocol, + required this.estimateContentLength, + required this.enableMpegtsM2TsMode, + required this.transcodeSeekInfo, + required this.copyTimestamps, + required this.context, + required this.maxAudioChannels, + required this.minSegments, + required this.segmentLength, + required this.breakOnNonKeyFrames, + required this.enableSubtitlesInManifest, + }); + + @HiveField(0) + String? container; + + /// Enum: "Audio" "Video" "Photo" + @HiveField(1) + String type; + + @HiveField(2) + String? videoCodec; + + @HiveField(3) + String? audioCodec; + + @HiveField(4) + String? protocol; + + @HiveField(5) + bool estimateContentLength; + + @HiveField(6) + bool enableMpegtsM2TsMode; + + /// Enum: "Auto" "Bytes" + @HiveField(7) + String transcodeSeekInfo; + + @HiveField(8) + bool copyTimestamps; + + /// Enum: "Streaming" "Static" + @HiveField(9) + String context; + + @HiveField(10) + String? maxAudioChannels; + + @HiveField(11) + int minSegments; + + @HiveField(12) + int segmentLength; + + @HiveField(13) + bool breakOnNonKeyFrames; + + // Below fields were added during null safety migration (0.5.0) + + @HiveField(14) + bool enableSubtitlesInManifest; + + factory TranscodingProfile.fromJson(Map json) => + _$TranscodingProfileFromJson(json); + Map toJson() => _$TranscodingProfileToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 23) +class ContainerProfile { + ContainerProfile({ + required this.type, + this.conditions, + this.container, + }); + + /// Enum: "Audio" "Video" "Photo" + @HiveField(0) + String type; + + @HiveField(1) + List? conditions; + + @HiveField(2) + String? container; + + factory ContainerProfile.fromJson(Map json) => + _$ContainerProfileFromJson(json); + Map toJson() => _$ContainerProfileToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 24) +class ProfileCondition { + ProfileCondition({ + required this.condition, + required this.property, + this.value, + required this.isRequired, + }); + + /// Enum: "Equals" "NotEquals" "LessThanEqual" "GreaterThanEqual" "EqualsAny" + @HiveField(0) + String condition; + + /// Enum: "AudioChannels" "AudioBitrate" "AudioProfile" "Width" "Height" + /// "Has64BitOffsets" "PacketLength" "VideoBitDepth" "VideoBitrate" + /// "VideoFramerate" "VideoLevel" "VideoProfile" "VideoTimestamp" + /// "IsAnamorphic" "RefFrames" "NumAudioStreams" "NumVideoStreams" + /// "IsSecondaryAudio" "VideoCodecTag" "IsAvc" "IsInterlaced" + /// "AudioSampleRate" "AudioBitDepth" + @HiveField(1) + String property; + + @HiveField(2) + String? value; + + @HiveField(3) + bool isRequired; + + factory ProfileCondition.fromJson(Map json) => + _$ProfileConditionFromJson(json); + Map toJson() => _$ProfileConditionToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 25) +class CodecProfile { + CodecProfile({ + required this.type, + this.conditions, + this.applyConditions, + this.codec, + this.container, + }); + + /// Enum: "Video" "VideoAudio" "Audio" + @HiveField(0) + String type; + + @HiveField(1) + List? conditions; + + @HiveField(2) + List? applyConditions; + + @HiveField(3) + String? codec; + + @HiveField(4) + String? container; + + factory CodecProfile.fromJson(Map json) => + _$CodecProfileFromJson(json); + Map toJson() => _$CodecProfileToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 26) +class ResponseProfile { + ResponseProfile({ + this.container, + this.audioCodec, + this.videoCodec, + required this.type, + this.orgPn, + this.mimeType, + this.conditions, + }); + + @HiveField(0) + String? container; + + @HiveField(1) + String? audioCodec; + + @HiveField(2) + String? videoCodec; + + /// Enum: "Audio" "Video" "Photo" + @HiveField(3) + String type; + + @HiveField(4) + String? orgPn; + + @HiveField(5) + String? mimeType; + + @HiveField(6) + List? conditions; + + factory ResponseProfile.fromJson(Map json) => + _$ResponseProfileFromJson(json); + Map toJson() => _$ResponseProfileToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 27) +class SubtitleProfile { + SubtitleProfile({ + this.format, + required this.method, + this.didlMode, + this.language, + this.container, + }); + + @HiveField(0) + String? format; + + /// Enum: "Encode" "Embed" "External" "Hls" + @HiveField(1) + String method; + + @HiveField(2) + String? didlMode; + + @HiveField(3) + String? language; + + @HiveField(4) + String? container; + + factory SubtitleProfile.fromJson(Map json) => + _$SubtitleProfileFromJson(json); + Map toJson() => _$SubtitleProfileToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 0) +class BaseItemDto { + BaseItemDto({ + this.name, + this.originalTitle, + this.serverId, + required this.id, + this.etag, + this.playlistItemId, + this.dateCreated, + this.extraType, + this.airsBeforeSeasonNumber, + this.airsAfterSeasonNumber, + this.airsBeforeEpisodeNumber, + this.canDelete, + this.canDownload, + this.hasSubtitles, + this.preferredMetadataLanguage, + this.preferredMetadataCountryCode, + this.supportsSync, + this.container, + this.sortName, + this.forcedSortName, + this.video3DFormat, + this.premiereDate, + this.externalUrls, + this.mediaSources, + this.criticRating, + this.productionLocations, + this.path, + this.officialRating, + this.customRating, + this.channelId, + this.channelName, + this.overview, + this.taglines, + this.genres, + this.communityRating, + this.runTimeTicks, + this.playAccess, + this.aspectRatio, + this.productionYear, + this.number, + this.channelNumber, + this.indexNumber, + this.indexNumberEnd, + this.parentIndexNumber, + this.remoteTrailers, + this.providerIds, + this.isFolder, + this.parentId, + this.type, + this.people, + this.studios, + this.genreItems, + this.parentLogoItemId, + this.parentBackdropItemId, + this.parentBackdropImageTags, + this.localTrailerCount, + this.userData, + this.recursiveItemCount, + this.childCount, + this.seriesName, + this.seriesId, + this.seasonId, + this.specialFeatureCount, + this.displayPreferencesId, + this.status, + this.airTime, + this.airDays, + this.tags, + this.primaryImageAspectRatio, + this.artists, + this.artistItems, + this.album, + this.collectionType, + this.displayOrder, + this.albumId, + this.albumPrimaryImageTag, + this.seriesPrimaryImageTag, + this.albumArtist, + this.albumArtists, + this.seasonName, + this.mediaStreams, + this.partCount, + this.imageTags, + this.backdropImageTags, + this.parentLogoImageTag, + this.parentArtItemId, + this.parentArtImageTag, + this.seriesThumbImageTag, + this.seriesStudio, + this.parentThumbItemId, + this.parentThumbImageTag, + this.parentPrimaryImageItemId, + this.parentPrimaryImageTag, + this.chapters, + this.locationType, + this.mediaType, + this.endDate, + this.lockedFields, + this.lockData, + this.width, + this.height, + this.cameraMake, + this.cameraModel, + this.software, + this.exposureTime, + this.focalLength, + this.imageOrientation, + this.aperture, + this.shutterSpeed, + this.latitude, + this.longitude, + this.altitude, + this.isoSpeedRating, + this.seriesTimerId, + this.channelPrimaryImageTag, + this.startDate, + this.completionPercentage, + this.isRepeat, + this.episodeTitle, + this.isMovie, + this.isSports, + this.isSeries, + this.isLive, + this.isNews, + this.isKids, + this.isPremiere, + this.timerId, + this.currentProgram, + this.movieCount, + this.seriesCount, + this.albumCount, + this.songCount, + this.musicVideoCount, + this.sourceType, + this.dateLastMediaAdded, + this.enableMediaSourceDisplay, + this.cumulativeRunTimeTicks, + this.isPlaceHolder, + this.isHD, + this.videoType, + this.mediaSourceCount, + this.screenshotImageTags, + this.imageBlurHashes, + this.isoType, + this.trailerCount, + this.programCount, + this.episodeCount, + this.artistCount, + this.programId, + this.channelType, + this.audio, + }); + + /// Gets or sets the name. + @HiveField(0) + String? name; + + @HiveField(1) + String? originalTitle; + + /// Gets or sets the server identifier. + @HiveField(2) + String? serverId; + + /// Gets or sets the id. + @HiveField(3) + String id; + + /// Gets or sets the etag. + @HiveField(4) + String? etag; + + /// Gets or sets the playlist item identifier. + @HiveField(5) + String? playlistItemId; + + /// Gets or sets the date created. + @HiveField(6) + String? dateCreated; + + @HiveField(7) + String? extraType; + + @HiveField(8) + int? airsBeforeSeasonNumber; + + @HiveField(9) + int? airsAfterSeasonNumber; + + @HiveField(10) + int? airsBeforeEpisodeNumber; + + @HiveField(11) + bool? canDelete; + + @HiveField(12) + bool? canDownload; + + @HiveField(13) + bool? hasSubtitles; + + @HiveField(14) + String? preferredMetadataLanguage; + + @HiveField(15) + String? preferredMetadataCountryCode; + + /// Gets or sets a value indicating whether [supports synchronize]. + @HiveField(16) + bool? supportsSync; + + @HiveField(17) + String? container; + + /// Gets or sets the name of the sort. + @HiveField(18) + String? sortName; + + @HiveField(19) + String? forcedSortName; + + /// Enum: "HalfSideBySide" "FullSideBySide" "FullTopAndBottom" + /// "HalfTopAndBottom" "MVC" + /// Gets or sets the video3 D format. + @HiveField(20) + String? video3DFormat; + + /// Gets or sets the premiere date. + @HiveField(21) + String? premiereDate; + + /// Gets or sets the external urls. + @HiveField(22) + List? externalUrls; + + /// Gets or sets the media versions. + @HiveField(23) + List? mediaSources; + + /// Gets or sets the critic rating. + @HiveField(24) + double? criticRating; + + @HiveField(25) + List? productionLocations; + + /// Gets or sets the path. + @HiveField(26) + String? path; + + /// Gets or sets the official rating. + @HiveField(27) + String? officialRating; + + /// Gets or sets the custom rating. + @HiveField(28) + String? customRating; + + /// Gets or sets the channel identifier. + @HiveField(29) + String? channelId; + + @HiveField(30) + String? channelName; + + /// Gets or sets the overview. + @HiveField(31) + String? overview; + + /// Gets or sets the taglines. + @HiveField(32) + List? taglines; + + /// Gets or sets the genres. + @HiveField(33) + List? genres; + + /// Gets or sets the community rating. + @HiveField(34) + double? communityRating; + + /// Gets or sets the run time ticks. + @HiveField(35) + int? runTimeTicks; + + /// Enum: "Full" "None" + /// Gets or sets the play access. + @HiveField(36) + String? playAccess; + + /// Gets or sets the aspect ratio. + @HiveField(37) + String? aspectRatio; + + /// Gets or sets the production year. + @HiveField(38) + int? productionYear; + + /// Gets or sets the number. + @HiveField(39) + String? number; + + @HiveField(40) + String? channelNumber; + + /// Gets or sets the index number. + @HiveField(41) + int? indexNumber; + + /// Gets or sets the index number end. + @HiveField(42) + int? indexNumberEnd; + + /// Gets or sets the parent index number. + @HiveField(43) + int? parentIndexNumber; + + /// Gets or sets the trailer urls. + @HiveField(44) + List? remoteTrailers; + + /// Gets or sets the provider ids. + @HiveField(45) + Map? providerIds; + + /// Gets or sets a value indicating whether this instance is folder. + @HiveField(46) + bool? isFolder; + + /// Gets or sets the parent id. + @HiveField(47) + String? parentId; + + /// Gets or sets the type. + @HiveField(48) + String? type; + + /// Gets or sets the people. + @HiveField(49) + List? people; + + /// Gets or sets the studios. + @HiveField(50) + List? studios; + + @HiveField(51) + List? genreItems; + + /// If the item does not have a logo, this will hold the Id of the Parent that + /// has one. + @HiveField(52) + String? parentLogoItemId; + + /// If the item does not have any backdrops, this will hold the Id of the + /// Parent that has one. + @HiveField(53) + String? parentBackdropItemId; + + /// Gets or sets the parent backdrop image tags. + @HiveField(54) + List? parentBackdropImageTags; + + /// Gets or sets the local trailer count. + @HiveField(55) + int? localTrailerCount; + + /// User data for this item based on the user it's being requested for. + @HiveField(56) + UserItemDataDto? userData; + + /// Gets or sets the recursive item count. + @HiveField(57) + int? recursiveItemCount; + + /// Gets or sets the child count. + @HiveField(58) + int? childCount; + + /// Gets or sets the name of the series. + @HiveField(59) + String? seriesName; + + /// Gets or sets the series id. + @HiveField(60) + String? seriesId; + + /// Gets or sets the season identifier. + @HiveField(61) + String? seasonId; + + /// Gets or sets the special feature count. + @HiveField(62) + int? specialFeatureCount; + + /// Gets or sets the display preferences id. + @HiveField(63) + String? displayPreferencesId; + + /// Gets or sets the status. + @HiveField(64) + String? status; + + /// Gets or sets the air time. + @HiveField(65) + String? airTime; + + /// Items Enum: "Sunday" "Monday" "Tuesday" "Wednesday" "Thursday" "Friday" + /// "Saturday" + /// Gets or sets the air days. + @HiveField(66) + List? airDays; + + /// Gets or sets the tags. + @HiveField(67) + List? tags; + + /// Gets or sets the primary image aspect ratio, after image enhancements. + @HiveField(68) + double? primaryImageAspectRatio; + + /// Gets or sets the artists. + @HiveField(69) + List? artists; + + /// Gets or sets the artist items. + @HiveField(70) + List? artistItems; + + /// Gets or sets the album. + @HiveField(71) + String? album; + + /// Gets or sets the type of the collection. + @HiveField(72) + String? collectionType; + + /// Gets or sets the display order. + @HiveField(73) + String? displayOrder; + + /// Gets or sets the album id. + @HiveField(74) + String? albumId; + + /// Gets or sets the album image tag. + @HiveField(75) + String? albumPrimaryImageTag; + + /// Gets or sets the series primary image tag. + @HiveField(76) + String? seriesPrimaryImageTag; + + /// Gets or sets the album artist. + @HiveField(77) + String? albumArtist; + + /// Gets or sets the album artists. + @HiveField(78) + List? albumArtists; + + /// Gets or sets the name of the season. + @HiveField(79) + String? seasonName; + + /// Gets or sets the media streams. + @HiveField(80) + List? mediaStreams; + + /// Gets or sets the part count. + @HiveField(81) + int? partCount; + + /// Gets or sets the image tags. + @HiveField(82) + Map? imageTags; + + /// Gets or sets the backdrop image tags. + @HiveField(83) + List? backdropImageTags; + + /// Gets or sets the parent logo image tag. + @HiveField(84) + String? parentLogoImageTag; + + /// If the item does not have a art, this will hold the Id of the Parent that + /// has one. + @HiveField(85) + String? parentArtItemId; + + /// Gets or sets the parent art image tag. + @HiveField(86) + String? parentArtImageTag; + + /// Gets or sets the series thumb image tag. + @HiveField(87) + String? seriesThumbImageTag; + + /// Gets or sets the series studio. + @HiveField(88) + String? seriesStudio; + + /// Gets or sets the parent thumb item id. + @HiveField(89) + String? parentThumbItemId; + + /// Gets or sets the parent thumb image tag. + @HiveField(90) + String? parentThumbImageTag; + + /// Gets or sets the parent primary image item identifier. + @HiveField(91) + String? parentPrimaryImageItemId; + + /// Gets or sets the parent primary image tag. + @HiveField(92) + String? parentPrimaryImageTag; + + /// Gets or sets the chapters. + @HiveField(93) + List? chapters; + + /// Enum: "FileSystem" "Remote" "Virtual" "Offline" + /// Gets or sets the type of the location. + @HiveField(94) + String? locationType; + + /// Gets or sets the type of the media. + @HiveField(95) + String? mediaType; + + /// Gets or sets the end date. + @HiveField(96) + String? endDate; + + /// Items Enum: "Cast" "Genres" "ProductionLocations" "Studios" "Tags" "Name" + /// "Overview" "Runtime" "OfficialRating" + /// Gets or sets the locked fields. + @HiveField(97) + List? lockedFields; + + /// Gets or sets a value indicating whether [enable internet providers]. + @HiveField(98) + bool? lockData; + + @HiveField(99) + int? width; + + @HiveField(100) + int? height; + + @HiveField(101) + String? cameraMake; + + @HiveField(102) + String? cameraModel; + + @HiveField(103) + String? software; + + @HiveField(104) + double? exposureTime; + + @HiveField(105) + double? focalLength; + + /// Enum: "TopLeft" "TopRight" "BottomRight" "BottomLeft" "LeftTop" "RightTop" + /// "RightBottom" "LeftBottom" + @HiveField(106) + String? imageOrientation; + + @HiveField(107) + double? aperture; + + @HiveField(108) + double? shutterSpeed; + + @HiveField(109) + double? latitude; + + @HiveField(110) + double? longitude; + + @HiveField(111) + double? altitude; + + @HiveField(112) + int? isoSpeedRating; + + /// Gets or sets the series timer identifier. + @HiveField(113) + String? seriesTimerId; + + /// Gets or sets the channel primary image tag. + @HiveField(114) + String? channelPrimaryImageTag; + + /// The start date of the recording, in UTC. + @HiveField(115) + String? startDate; + + /// Gets or sets the completion percentage. + @HiveField(116) + double? completionPercentage; + + /// Gets or sets a value indicating whether this instance is repeat. + @HiveField(117) + bool? isRepeat; + + /// Gets or sets the episode title. + @HiveField(118) + String? episodeTitle; + + /// Gets or sets a value indicating whether this instance is movie. + @HiveField(119) + bool? isMovie; + + /// Gets or sets a value indicating whether this instance is sports. + @HiveField(120) + bool? isSports; + + /// Gets or sets a value indicating whether this instance is series. + @HiveField(121) + bool? isSeries; + + /// Gets or sets a value indicating whether this instance is live. + @HiveField(122) + bool? isLive; + + /// Gets or sets a value indicating whether this instance is news. + @HiveField(123) + bool? isNews; + + /// Gets or sets a value indicating whether this instance is kids. + @HiveField(124) + bool? isKids; + + /// Gets or sets a value indicating whether this instance is premiere. + @HiveField(125) + bool? isPremiere; + + /// Gets or sets the timer identifier. + @HiveField(126) + String? timerId; + + /// Gets or sets the current program. + @HiveField(127) + dynamic currentProgram; + + /// Gets or sets the movie count. + @HiveField(128) + int? movieCount; + + /// Gets or sets the series count. + @HiveField(129) + int? seriesCount; + + /// Gets or sets the album count. + @HiveField(130) + int? albumCount; + + /// Gets or sets the song count. + @HiveField(131) + int? songCount; + + /// Gets or sets the music video count. + @HiveField(132) + int? musicVideoCount; + + // Below fields were added during null safety migration (0.5.0) + + /// Gets or sets the type of the source. + @HiveField(133) + String? sourceType; + + @HiveField(134) + String? dateLastMediaAdded; + + @HiveField(135) + bool? enableMediaSourceDisplay; + + /// Gets or sets the cumulative run time ticks. + @HiveField(136) + int? cumulativeRunTimeTicks; + + /// Gets or sets a value indicating whether this instance is place holder. + @HiveField(137) + bool? isPlaceHolder; + + /// Gets or sets a value indicating whether this instance is HD. + @HiveField(138) + bool? isHD; + + /// Enum: "VideoFile" "Iso" "Dvd" "BluRay" + /// Gets or sets the type of the video. + @HiveField(139) + String? videoType; + + @HiveField(140) + int? mediaSourceCount; + + /// Gets or sets the screenshot image tags. + @HiveField(141) + List? screenshotImageTags; + + /// Gets or sets the blurhashes for the image tags. Maps image type to + /// dictionary mapping image tag to blurhash value. + @HiveField(142) + ImageBlurHashes? imageBlurHashes; + + /// Enum: "Dvd" "BluRay" + /// Gets or sets the type of the iso. + @HiveField(143) + String? isoType; + + /// Gets or sets the trailer count. + @HiveField(144) + int? trailerCount; + + @HiveField(145) + int? programCount; + + /// Gets or sets the episode count. + @HiveField(146) + int? episodeCount; + + @HiveField(147) + int? artistCount; + + /// Gets or sets the program identifier. + @HiveField(148) + String? programId; + + /// Enum: "TV" "Radio" + /// Gets or sets the type of the channel. + @HiveField(149) + String? channelType; + + /// Enum: "Mono" "Stereo" "Dolby" "DolbyDigital" "Thx" "Atmos" + /// Gets or sets the audio. + @HiveField(150) + String? audio; + + /// Checks if the item has its own image (not inherited from a parent) + bool get hasOwnImage => imageTags?.containsKey("Primary") ?? false; + + /// String form of [productionYear], with substitution if null + String get productionYearString => + productionYear?.toString() ?? "Unknown Year"; + + /// Gets the image id of a given item. If the item has its own image, it will + /// return the item id. Otherwise, if the item's parent has an image ID, it + /// returns that ID. Otherwise, if the item is an album and has an album ID, + /// it will return that. If the item meets none of these conditions, this + /// function will return null. + String? get imageId { + if (hasOwnImage) { + return id; + } else if (parentPrimaryImageItemId != null) { + return parentPrimaryImageItemId; + } else if (albumId != null && albumPrimaryImageTag != null) { + return albumId; + } + return null; + } + + /// The first primary blurhash of this item. + String? get blurHash => imageBlurHashes?.primary?.values.first; + + /// The name of the song to use when sorting. This getter strips words that + /// are removed by Jellyfin (the, a, an). + String? get nameForSorting { + if (sortName != null) { + return sortName; + } + + if (name == null) { + return null; + } + + // https://github.com/jellyfin/jellyfin/blob/054f42332d8e0c45fb899eeaef982aa0fd549397/MediaBrowser.Model/Configuration/ServerConfiguration.cs#L129 + // Should probably also do SortRemoveCharacters? + const sortRemoveWords = ["the", "a", "an"]; + + for (final word in sortRemoveWords) { + if (name!.toLowerCase().startsWith(word)) { + var strippedName = name!.substring(word.length); + + // Remove any leading spaces that could've occured due to removing prefixes. + // For example, "The Black Parade" shouldn't become " Black Parade" + strippedName = strippedName.trimLeft(); + + return strippedName; + } + } + + return name; + } + + factory BaseItemDto.fromJson(Map json) => + _$BaseItemDtoFromJson(json); + Map toJson() => _$BaseItemDtoToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 29) +class ExternalUrl { + ExternalUrl({ + this.name, + this.url, + }); + + @HiveField(0) + String? name; + + @HiveField(1) + String? url; + + factory ExternalUrl.fromJson(Map json) => + _$ExternalUrlFromJson(json); + Map toJson() => _$ExternalUrlToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 5) +class MediaSourceInfo { + MediaSourceInfo({ + required this.protocol, + this.id, + this.path, + this.encoderPath, + this.encoderProtocol, + required this.type, + this.container, + this.size, + this.name, + required this.isRemote, + this.runTimeTicks, + required this.supportsTranscoding, + required this.supportsDirectStream, + required this.supportsDirectPlay, + required this.isInfiniteStream, + required this.requiresOpening, + this.openToken, + required this.requiresClosing, + this.liveStreamId, + this.bufferMs, + required this.requiresLooping, + required this.supportsProbing, + this.video3DFormat, + required this.mediaStreams, + this.formats, + this.bitrate, + this.timestamp, + this.requiredHttpHeaders, + this.transcodingUrl, + this.transcodingSubProtocol, + this.transcodingContainer, + this.analyzeDurationMs, + required this.readAtNativeFramerate, + this.defaultAudioStreamIndex, + this.defaultSubtitleStreamIndex, + this.etag, + required this.ignoreDts, + required this.ignoreIndex, + required this.genPtsInput, + this.videoType, + this.isoType, + this.mediaAttachments, + }); + + /// Enum: "File" "Http" "Rtmp" "Rtsp" "Udp" "Rtp" "Ftp" + @HiveField(0) + String protocol; + + @HiveField(1) + String? id; + + @HiveField(2) + String? path; + + @HiveField(3) + String? encoderPath; + + /// Enum: "File" "Http" "Rtmp" "Rtsp" "Udp" "Rtp" "Ftp" + @HiveField(4) + String? encoderProtocol; + + /// Enum: "Default" "Grouping" "Placeholder" + @HiveField(5) + String type; + + @HiveField(6) + String? container; + + @HiveField(7) + int? size; + + @HiveField(8) + String? name; + + /// Differentiate internet url vs local network. + @HiveField(9) + bool isRemote; + + @HiveField(10) + int? runTimeTicks; + + @HiveField(11) + bool supportsTranscoding; + + @HiveField(12) + bool supportsDirectStream; + + @HiveField(13) + bool supportsDirectPlay; + + @HiveField(14) + bool isInfiniteStream; + + @HiveField(15) + bool requiresOpening; + + @HiveField(16) + String? openToken; + + @HiveField(17) + bool requiresClosing; + + @HiveField(18) + String? liveStreamId; + + @HiveField(19) + int? bufferMs; + + @HiveField(20) + bool requiresLooping; + + @HiveField(21) + bool supportsProbing; + + /// Enum: "HalfSideBySide" "FullSideBySide" "FullTopAndBottom" + /// "HalfTopAndBottom" "MVC" + @HiveField(22) + String? video3DFormat; + + @HiveField(23) + List mediaStreams; + + @HiveField(24) + List? formats; + + @HiveField(25) + int? bitrate; + + /// Enum: "None" "Zero" "Valid" + @HiveField(26) + String? timestamp; + + @HiveField(27) + Map? requiredHttpHeaders; + + @HiveField(28) + String? transcodingUrl; + + @HiveField(29) + String? transcodingSubProtocol; + + @HiveField(30) + String? transcodingContainer; + + @HiveField(31) + int? analyzeDurationMs; + + @HiveField(32) + bool readAtNativeFramerate; + + @HiveField(33) + int? defaultAudioStreamIndex; + + @HiveField(34) + int? defaultSubtitleStreamIndex; + + // Below fields were added during null safety migration (0.5.0) + + @HiveField(35) + String? etag; + + @HiveField(36) + bool ignoreDts; + + @HiveField(37) + bool ignoreIndex; + + @HiveField(38) + bool genPtsInput; + + /// Enum: "VideoFile" "Iso" "Dvd" "BluRay" + /// Enum VideoType. + @HiveField(39) + String? videoType; + + /// Enum: "Dvd" "BluRay" + /// Enum IsoType. + @HiveField(40) + String? isoType; + + @HiveField(41) + List? mediaAttachments; + + factory MediaSourceInfo.fromJson(Map json) => + _$MediaSourceInfoFromJson(json); + Map toJson() => _$MediaSourceInfoToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 6) +class MediaStream { + MediaStream({ + this.codec, + this.codecTag, + this.language, + this.colorTransfer, + this.colorPrimaries, + this.colorSpace, + this.comment, + this.timeBase, + this.codecTimeBase, + this.title, + this.videoRange, + this.displayTitle, + this.nalLengthSize, + required this.isInterlaced, + this.isAVC, + this.channelLayout, + this.bitRate, + this.bitDepth, + this.refFrames, + this.packetLength, + this.channels, + this.sampleRate, + required this.isDefault, + required this.isForced, + this.height, + this.width, + this.averageFrameRate, + this.realFrameRate, + this.profile, + required this.type, + this.aspectRatio, + required this.index, + this.score, + required this.isExternal, + this.deliveryMethod, + this.deliveryUrl, + this.isExternalUrl, + required this.isTextSubtitleStream, + required this.supportsExternalStream, + this.path, + this.pixelFormat, + this.level, + this.isAnamorphic, + }); + + /// Gets or sets the codec. + @HiveField(0) + String? codec; + + /// Gets or sets the codec tag. + @HiveField(1) + String? codecTag; + + /// Gets or sets the language. + @HiveField(2) + String? language; + + /// Gets or sets the color transfer. + @HiveField(3) + String? colorTransfer; + + /// Gets or sets the color primaries. + @HiveField(4) + String? colorPrimaries; + + /// Gets or sets the color space. + @HiveField(5) + String? colorSpace; + + /// Gets or sets the comment. + @HiveField(6) + String? comment; + + /// Gets or sets the time base. + @HiveField(7) + String? timeBase; + + /// Gets or sets the codec time base. + @HiveField(8) + String? codecTimeBase; + + /// Gets or sets the title. + @HiveField(9) + String? title; + + /// Gets or sets the video range. + @HiveField(10) + String? videoRange; + + @HiveField(11) + String? displayTitle; + + @HiveField(12) + String? nalLengthSize; + + /// Gets or sets a value indicating whether this instance is interlaced. + @HiveField(13) + bool isInterlaced; + + @HiveField(14) + bool? isAVC; + + /// Gets or sets the channel layout. + @HiveField(15) + String? channelLayout; + + /// Gets or sets the bit rate. + @HiveField(16) + int? bitRate; + + /// Gets or sets the bit depth. + @HiveField(17) + int? bitDepth; + + /// Gets or sets the reference frames. + @HiveField(18) + int? refFrames; + + /// Gets or sets the length of the packet. + @HiveField(19) + int? packetLength; + + /// Gets or sets the channels. + @HiveField(20) + int? channels; + + /// Gets or sets the sample rate. + @HiveField(21) + int? sampleRate; + + /// Gets or sets a value indicating whether this instance is default. + @HiveField(22) + bool isDefault; + + /// Gets or sets a value indicating whether this instance is forced. + @HiveField(23) + bool isForced; + + /// Gets or sets the height. + @HiveField(24) + int? height; + + /// Gets or sets the width. + @HiveField(25) + int? width; + + /// Gets or sets the average frame rate. + @HiveField(26) + double? averageFrameRate; + + /// Gets or sets the real frame rate. + @HiveField(27) + double? realFrameRate; + + /// Gets or sets the profile. + @HiveField(28) + String? profile; + + /// Enum: "Audio" "Video" "Subtitle" "EmbeddedImage" + /// Gets or sets the type. + @HiveField(29) + String type; + + /// Gets or sets the aspect ratio. + @HiveField(30) + String? aspectRatio; + + /// Gets or sets the index. + @HiveField(31) + int index; + + /// Gets or sets the score. + @HiveField(32) + int? score; + + /// Gets or sets a value indicating whether this instance is external. + @HiveField(33) + bool isExternal; + + /// Enum: "Encode" "Embed" "External" "Hls" + /// Gets or sets the method. + @HiveField(34) + String? deliveryMethod; + + /// Gets or sets the delivery URL. + @HiveField(35) + String? deliveryUrl; + + /// Gets or sets a value indicating whether this instance is external URL. + @HiveField(36) + bool? isExternalUrl; + + @HiveField(37) + bool isTextSubtitleStream; + + /// Gets or sets a value indicating whether [supports external stream]. + @HiveField(38) + bool supportsExternalStream; + + /// Gets or sets the filename. + @HiveField(39) + String? path; + + /// Gets or sets the pixel format. + @HiveField(40) + String? pixelFormat; + + /// Gets or sets the level. + @HiveField(41) + double? level; + + /// Gets a value indicating whether this instance is anamorphic. + @HiveField(42) + bool? isAnamorphic; + + // Below fields were added during null safety migration (0.5.0) + + /// Gets or sets the color range. + @HiveField(43) + String? colorRange; + + @HiveField(44) + String? localizedUndefined; + + @HiveField(45) + String? localizedDefault; + + @HiveField(46) + String? localizedForced; + + factory MediaStream.fromJson(Map json) => + _$MediaStreamFromJson(json); + Map toJson() => _$MediaStreamToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +class MediaUrl { + MediaUrl({this.url, this.name}); + + String? url; + + String? name; + + factory MediaUrl.fromJson(Map json) => + _$MediaUrlFromJson(json); + Map toJson() => _$MediaUrlToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +class BaseItemPerson { + BaseItemPerson({ + this.name, + this.id, + this.role, + this.type, + this.primaryImageTag, + }); + + /// Gets or sets the name. + String? name; + + /// Gets or sets the identifier. + String? id; + + /// Gets or sets the role. + String? role; + + /// Gets or sets the type. + String? type; + + /// Gets or sets the primary image tag. + String? primaryImageTag; + + /// Gets or sets the primary image blurhash. + ImageBlurHashes? imageBlurHashes; + + factory BaseItemPerson.fromJson(Map json) => + _$BaseItemPersonFromJson(json); + Map toJson() => _$BaseItemPersonToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 30) +class NameLongIdPair { + NameLongIdPair({ + this.name, + required this.id, + }); + + @HiveField(0) + String? name; + + @HiveField(1) + String id; + + factory NameLongIdPair.fromJson(Map json) => + _$NameLongIdPairFromJson(json); + Map toJson() => _$NameLongIdPairToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 1) +class UserItemDataDto { + UserItemDataDto({ + this.rating, + this.playedPercentage, + this.unplayedItemCount, + required this.playbackPositionTicks, + required this.playCount, + required this.isFavorite, + this.likes, + this.lastPlayedDate, + required this.played, + this.key, + this.itemId, + }); + + /// Gets or sets the rating. + @HiveField(0) + double? rating; + + /// Gets or sets the played percentage. + @HiveField(1) + double? playedPercentage; + + /// Gets or sets the unplayed item count. + @HiveField(2) + int? unplayedItemCount; + + /// Gets or sets the playback position ticks. + @HiveField(3) + int playbackPositionTicks; + + /// Gets or sets the play count. + @HiveField(4) + int playCount; + + /// Gets or sets a value indicating whether this instance is favorite. + @HiveField(5) + bool isFavorite; + + /// Gets or sets a value indicating whether this + /// MediaBrowser.Model.Dto.UserItemDataDto is likes. + @HiveField(6) + bool? likes; + + /// Gets or sets the last played date. + @HiveField(7) + String? lastPlayedDate; + + /// Gets or sets a value indicating whether this + /// MediaBrowser.Model.Dto.UserItemDataDto is played. + @HiveField(8) + bool played; + + /// Gets or sets the key. + @HiveField(9) + String? key; + + /// Gets or sets the item identifier. + @HiveField(10) + String? itemId; + + factory UserItemDataDto.fromJson(Map json) => + _$UserItemDataDtoFromJson(json); + Map toJson() => _$UserItemDataDtoToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +@HiveType(typeId: 2) +class NameIdPair { + NameIdPair({ + this.name, + required this.id, + }); + + @HiveField(0) + String? name; + + @HiveField(1) + String id; + + factory NameIdPair.fromJson(Map json) => + _$NameIdPairFromJson(json); + Map toJson() => _$NameIdPairToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +class ChapterInfo { + ChapterInfo({ + required this.startPositionTicks, + this.name, + this.imageTag, + this.imagePath, + required this.imageDateModified, + }); + + /// Gets or sets the start position ticks. + int startPositionTicks; + + /// Gets or sets the name. + String? name; + + String? imageTag; + + // Below fields were added during null safety migration (0.5.0) + + /// Gets or sets the image path. + String? imagePath; + + String imageDateModified; + + factory ChapterInfo.fromJson(Map json) => + _$ChapterInfoFromJson(json); + Map toJson() => _$ChapterInfoToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +// ignore: camel_case_types +class QueryResult_BaseItemDto { + QueryResult_BaseItemDto({ + this.items, + required this.totalRecordCount, + required this.startIndex, + }); + + /// Gets or sets the items. + List? items; + + /// The total number of records available. + int totalRecordCount; + + /// The index of the first record in Items. + int startIndex; + + factory QueryResult_BaseItemDto.fromJson(Map json) => + _$QueryResult_BaseItemDtoFromJson(json); + Map toJson() => _$QueryResult_BaseItemDtoToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +class PlaybackInfoResponse { + PlaybackInfoResponse({ + this.mediaSources, + this.playSessionId, + this.errorCode, + }); + + /// Gets or sets the media sources. + List? mediaSources; + + /// Gets or sets the play session identifier. + String? playSessionId; + + /// Enum: "NotAllowed" "NoCompatibleStream" "RateLimitExceeded" + /// Gets or sets the error code. + String? errorCode; + + factory PlaybackInfoResponse.fromJson(Map json) => + _$PlaybackInfoResponseFromJson(json); + Map toJson() => _$PlaybackInfoResponseToJson(this); +} + +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +class PlaybackProgressInfo { + PlaybackProgressInfo({ + this.canSeek = true, + this.item, + required this.itemId, + this.sessionId, + this.mediaSourceId, + this.audioStreamIndex, + this.subtitleStreamIndex, + required this.isPaused, + required this.isMuted, + this.positionTicks, + this.playbackStartTimeTicks, + this.volumeLevel, + this.brightness, + this.aspectRatio, + this.playMethod = "DirectPlay", + this.liveStreamId, + this.playSessionId, + required this.repeatMode, + this.nowPlayingQueue, + this.playlistItemId, + }); + + /// Gets or sets a value indicating whether this instance can seek. + bool canSeek; + + /// Gets or sets the item. + BaseItemDto? item; + + /// Gets or sets the item identifier. + String itemId; + + /// Gets or sets the session id. + String? sessionId; + + /// Gets or sets the media version identifier. + String? mediaSourceId; + + /// Gets or sets the index of the audio stream. + int? audioStreamIndex; + + /// Gets or sets the index of the subtitle stream. + int? subtitleStreamIndex; + + /// Gets or sets a value indicating whether this instance is paused. + bool isPaused; + + /// Gets or sets a value indicating whether this instance is muted. + bool isMuted; + + /// Gets or sets the position ticks. + int? positionTicks; + + int? playbackStartTimeTicks; + + /// Gets or sets the volume level. + int? volumeLevel; + + int? brightness; + + String? aspectRatio; + + /// Enum: "Transcode" "DirectStream" "DirectPlay" + /// Gets or sets the play method. + String playMethod; + + /// Gets or sets the live stream identifier. + String? liveStreamId; + + /// Gets or sets the play session identifier. + String? playSessionId; + + /// Enum: "RepeatNone" "RepeatAll" "RepeatOne" + /// Gets or sets the repeat mode. + String repeatMode; + + List? nowPlayingQueue; + + String? playlistItemId; + + factory PlaybackProgressInfo.fromJson(Map json) => + _$PlaybackProgressInfoFromJson(json); + Map toJson() => _$PlaybackProgressInfoToJson(this); +} + +@HiveType(typeId: 32) +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +class ImageBlurHashes { + ImageBlurHashes({ + this.primary, + this.art, + this.backdrop, + this.banner, + this.logo, + this.thumb, + this.disc, + this.box, + this.screenshot, + this.menu, + this.chapter, + this.boxRear, + this.profile, + }); + + @HiveField(0) + Map? primary; + + @HiveField(1) + Map? art; + + @HiveField(2) + Map? backdrop; + + @HiveField(3) + Map? banner; + + @HiveField(4) + Map? logo; + + @HiveField(5) + Map? thumb; + + @HiveField(6) + Map? disc; + + @HiveField(7) + Map? box; + + @HiveField(8) + Map? screenshot; + + @HiveField(9) + Map? menu; + + @HiveField(10) + Map? chapter; + + @HiveField(11) + Map? boxRear; + + @HiveField(12) + Map? profile; + + factory ImageBlurHashes.fromJson(Map json) => + _$ImageBlurHashesFromJson(json); + Map toJson() => _$ImageBlurHashesToJson(this); +} + +@HiveType(typeId: 33) +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +class MediaAttachment { + MediaAttachment({ + this.codec, + this.codecTag, + this.comment, + required this.index, + this.fileName, + this.mimeType, + this.deliveryUrl, + }); + + /// Gets or sets the codec. + @HiveField(0) + String? codec; + + /// Gets or sets the codec tag. + @HiveField(1) + String? codecTag; + + /// Gets or sets the comment. + @HiveField(2) + String? comment; + + /// Gets or sets the index. + @HiveField(3) + int index; + + /// Gets or sets the filename. + @HiveField(4) + String? fileName; + + /// Gets or sets the MIME type. + @HiveField(5) + String? mimeType; + + /// Gets or sets the delivery URL. + @HiveField(6) + String? deliveryUrl; + + factory MediaAttachment.fromJson(Map json) => + _$MediaAttachmentFromJson(json); + Map toJson() => _$MediaAttachmentToJson(this); +} + +@HiveType(typeId: 34) +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +class BaseItem { + BaseItem({ + this.size, + this.container, + this.dateLastSaved, + this.remoteTrailers, + required this.isHD, + required this.isShortcut, + this.shortcutPath, + this.width, + this.height, + this.extraIds, + required this.supportsExternalTransfer, + }); + + @HiveField(0) + int? size; + + @HiveField(1) + String? container; + + @HiveField(2) + String? dateLastSaved; + + /// Gets or sets the remote trailers. + @HiveField(3) + List? remoteTrailers; + + @HiveField(4) + bool isHD; + + @HiveField(5) + bool isShortcut; + + @HiveField(6) + String? shortcutPath; + + @HiveField(7) + int? width; + + @HiveField(8) + int? height; + + @HiveField(9) + List? extraIds; + + @HiveField(10) + bool supportsExternalTransfer; + + factory BaseItem.fromJson(Map json) => + _$BaseItemFromJson(json); + Map toJson() => _$BaseItemToJson(this); +} + +@HiveType(typeId: 35) +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +class QueueItem { + QueueItem({ + required this.id, + this.playlistItemId, + }); + + @HiveField(0) + String id; + + @HiveField(1) + String? playlistItemId; + + factory QueueItem.fromJson(Map json) => + _$QueueItemFromJson(json); + Map toJson() => _$QueueItemToJson(this); +} + +/// Class used to create new playlist. Not returned from Jellyfin. +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +class NewPlaylist { + NewPlaylist({ + this.name, + required this.ids, + this.userId, + this.mediaType, + }); + + /// Gets or sets the name of the new playlist. + String? name; + + /// Gets or sets item ids to add to the playlist. + List ids; + + /// Gets or sets the user id. Required when creating playlists, but not adding + /// to them. + String? userId; + + /// Gets or sets the media type. + String? mediaType; + + factory NewPlaylist.fromJson(Map json) => + _$NewPlaylistFromJson(json); + Map toJson() => _$NewPlaylistToJson(this); +} + +/// Jellyfin response when creating new playlist. +@JsonSerializable( + fieldRename: FieldRename.pascal, + explicitToJson: true, + anyMap: true, +) +class NewPlaylistResponse { + NewPlaylistResponse({this.id}); + + String? id; + + factory NewPlaylistResponse.fromJson(Map json) => + _$NewPlaylistResponseFromJson(json); + Map toJson() => _$NewPlaylistResponseToJson(this); +} + +/// Enum for sort types. +@HiveType(typeId: 37) +enum SortBy { + @HiveField(0) + album, + + @HiveField(1) + albumArtist, + + @HiveField(2) + artist, + + @HiveField(3) + budget, + + @HiveField(4) + communityRating, + + @HiveField(5) + criticRating, + + @HiveField(6) + dateCreated, + + @HiveField(7) + datePlayed, + + @HiveField(8) + playCount, + + @HiveField(9) + premiereDate, + + @HiveField(10) + productionYear, + + @HiveField(11) + sortName, + + @HiveField(12) + random, + + @HiveField(13) + revenue, + + @HiveField(14) + runtime; + + /// default SortBy options shown to the user, such as in the sort by menu + static List get defaults => [ + SortBy.sortName, + SortBy.albumArtist, + SortBy.communityRating, + SortBy.criticRating, + SortBy.dateCreated, + SortBy.premiereDate, + SortBy.random, + ]; + + /// Human-readable version of the [SortBy]. For example, toString() on + /// [SortBy.album], toString() would return "SortBy.album". With this + /// function, the same input would return "Album". + @override + @Deprecated("Use toLocalisedString when possible") + String toString() => _humanReadableName(this); + + String toLocalisedString(BuildContext context) => + _humanReadableLocalisedName(this, context); + + /// Name used by Jellyfin in API requests. + String jellyfinName(TabContentType contentType) { + switch (contentType) { + case TabContentType.albums: + return _jellyfinNameMusicAlbums(this); + case TabContentType.songs: + return _jellyfinNameSongs(this); + default: + return _jellyfinName(this); + } + } + + String _humanReadableName(SortBy sortBy) { + switch (sortBy) { + case SortBy.album: + return "Album"; + case SortBy.albumArtist: + return "Album Artist"; + case SortBy.artist: + return "Artist"; + case SortBy.budget: + return "Budget"; + case SortBy.communityRating: + return "Community Rating"; + case SortBy.criticRating: + return "Critic Rating"; + case SortBy.dateCreated: + return "Date Added"; + case SortBy.datePlayed: + return "Date Played"; + case SortBy.playCount: + return "Play Count"; + case SortBy.premiereDate: + return "Premiere Date"; + case SortBy.productionYear: + return "Production Year"; + case SortBy.sortName: + return "Name"; + case SortBy.random: + return "Random"; + case SortBy.revenue: + return "Revenue"; + case SortBy.runtime: + return "Runtime"; + } + } + + String _humanReadableLocalisedName(SortBy sortBy, BuildContext context) { + switch (sortBy) { + case SortBy.album: + return AppLocalizations.of(context)!.album; + case SortBy.albumArtist: + return AppLocalizations.of(context)!.albumArtist; + case SortBy.artist: + return AppLocalizations.of(context)!.artist; + case SortBy.budget: + return AppLocalizations.of(context)!.budget; + case SortBy.communityRating: + return AppLocalizations.of(context)!.communityRating; + case SortBy.criticRating: + return AppLocalizations.of(context)!.criticRating; + case SortBy.dateCreated: + return AppLocalizations.of(context)!.dateAdded; + case SortBy.datePlayed: + return AppLocalizations.of(context)!.datePlayed; + case SortBy.playCount: + return AppLocalizations.of(context)!.playCount; + case SortBy.premiereDate: + return AppLocalizations.of(context)!.premiereDate; + case SortBy.productionYear: + return AppLocalizations.of(context)!.productionYear; + case SortBy.sortName: + return AppLocalizations.of(context)!.name; + case SortBy.random: + return AppLocalizations.of(context)!.random; + case SortBy.revenue: + return AppLocalizations.of(context)!.revenue; + case SortBy.runtime: + return AppLocalizations.of(context)!.runtime; + } + } + + String _jellyfinName(SortBy sortBy) { + switch (sortBy) { + case SortBy.album: + return "Album"; + case SortBy.albumArtist: + return "AlbumArtist"; + case SortBy.artist: + return "Artist"; + case SortBy.budget: + return "Budget"; + case SortBy.communityRating: + return "CommunityRating"; + case SortBy.criticRating: + return "CriticRating"; + case SortBy.dateCreated: + return "DateCreated"; + case SortBy.datePlayed: + return "DatePlayed"; + case SortBy.playCount: + return "PlayCount"; + case SortBy.premiereDate: + return "PremiereDate"; + case SortBy.productionYear: + return "ProductionYear"; + case SortBy.sortName: + return "SortName"; + case SortBy.random: + return "Random"; + case SortBy.revenue: + return "Revenue"; + case SortBy.runtime: + return "Runtime"; + } + } + + String _jellyfinNameMusicAlbums(SortBy sortBy) { + switch (sortBy) { + case SortBy.album: + return "Album"; + case SortBy.albumArtist: + return "AlbumArtist,SortName"; + case SortBy.artist: + return "Artist"; + case SortBy.budget: + return "Budget"; + case SortBy.communityRating: + return "CommunityRating,SortName"; + case SortBy.criticRating: + return "CriticRating,SortName"; + case SortBy.dateCreated: + return "DateCreated,SortName"; + case SortBy.datePlayed: + return "DatePlayed"; + case SortBy.playCount: + return "PlayCount"; + case SortBy.premiereDate: + return "PremiereDate"; + case SortBy.productionYear: + return "ProductionYear,PremiereDate,SortName"; + case SortBy.sortName: + return "SortName"; + case SortBy.random: + return "Random,SortName"; + case SortBy.revenue: + return "Revenue"; + case SortBy.runtime: + return "Runtime"; + } + } + + String _jellyfinNameSongs(SortBy sortBy) { + switch (sortBy) { + case SortBy.album: + return "Album,SortName"; + case SortBy.albumArtist: + return "AlbumArtist,Album,SortName"; + case SortBy.artist: + return "Artist,Album,SortName"; + case SortBy.budget: + return "Budget"; + case SortBy.communityRating: + return "CommunityRating"; + case SortBy.criticRating: + return "CriticRating"; + case SortBy.dateCreated: + return "DateCreated,SortName"; + case SortBy.datePlayed: + return "DatePlayed,SortName"; + case SortBy.playCount: + return "PlayCount,SortName"; + case SortBy.premiereDate: + return "PremiereDate,AlbumArtist,Album,SortName"; + case SortBy.productionYear: + return "ProductionYear"; + case SortBy.sortName: + return "Name"; + case SortBy.random: + return "Random"; + case SortBy.revenue: + return "Revenue"; + case SortBy.runtime: + return "Runtime,AlbumArtist,Album,SortName"; + } + } +} + +/// Enum for sort orders. +@HiveType(typeId: 38) +enum SortOrder { + @HiveField(0) + ascending, + + @HiveField(1) + descending; + + /// Human-readable version of the [SortOrder]. For example, toString() on + /// [SortOrder.ascending], toString() would return "SortOrder.ascending". With + /// this function, the same input would return "Ascending". This function is + /// also used in Jellyfin API calls, but another function will be needed when + /// localisation support is added. + @override + String toString() => _humanReadableName(this); + + String _humanReadableName(SortOrder sortOrder) { + switch (sortOrder) { + case SortOrder.ascending: + return "Ascending"; + case SortOrder.descending: + return "Descending"; + } + } +} diff --git a/lib/models/jellyfin_models.g.dart b/lib/models/jellyfin_models.g.dart new file mode 100644 index 0000000..d86f677 --- /dev/null +++ b/lib/models/jellyfin_models.g.dart @@ -0,0 +1,4333 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'jellyfin_models.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class UserDtoAdapter extends TypeAdapter { + @override + final int typeId = 9; + + @override + UserDto read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return UserDto( + name: fields[0] as String?, + serverId: fields[1] as String?, + serverName: fields[2] as String?, + id: fields[3] as String, + primaryImageTag: fields[4] as String?, + hasPassword: fields[5] as bool, + hasConfiguredPassword: fields[6] as bool, + hasConfiguredEasyPassword: fields[7] as bool, + enableAutoLogin: fields[8] as bool?, + lastLoginDate: fields[9] as String?, + lastActivityDate: fields[10] as String?, + configuration: fields[11] as UserConfiguration?, + policy: fields[12] as UserPolicy?, + primaryImageAspectRatio: fields[13] as double?, + ); + } + + @override + void write(BinaryWriter writer, UserDto obj) { + writer + ..writeByte(14) + ..writeByte(0) + ..write(obj.name) + ..writeByte(1) + ..write(obj.serverId) + ..writeByte(2) + ..write(obj.serverName) + ..writeByte(3) + ..write(obj.id) + ..writeByte(4) + ..write(obj.primaryImageTag) + ..writeByte(5) + ..write(obj.hasPassword) + ..writeByte(6) + ..write(obj.hasConfiguredPassword) + ..writeByte(7) + ..write(obj.hasConfiguredEasyPassword) + ..writeByte(8) + ..write(obj.enableAutoLogin) + ..writeByte(9) + ..write(obj.lastLoginDate) + ..writeByte(10) + ..write(obj.lastActivityDate) + ..writeByte(11) + ..write(obj.configuration) + ..writeByte(12) + ..write(obj.policy) + ..writeByte(13) + ..write(obj.primaryImageAspectRatio); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is UserDtoAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class UserConfigurationAdapter extends TypeAdapter { + @override + final int typeId = 11; + + @override + UserConfiguration read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return UserConfiguration( + audioLanguagePreference: fields[0] as String?, + playDefaultAudioTrack: fields[1] as bool, + subtitleLanguagePreference: fields[2] as String?, + displayMissingEpisodes: fields[3] as bool, + groupedFolders: (fields[4] as List?)?.cast(), + subtitleMode: fields[5] as String, + displayCollectionsView: fields[6] as bool, + enableLocalPassword: fields[7] as bool, + orderedViews: (fields[8] as List?)?.cast(), + latestItemsExcludes: (fields[9] as List?)?.cast(), + myMediaExcludes: (fields[10] as List?)?.cast(), + hidePlayedInLatest: fields[11] as bool, + rememberAudioSelections: fields[12] as bool, + rememberSubtitleSelections: fields[13] as bool, + enableNextEpisodeAutoPlay: fields[14] as bool, + ); + } + + @override + void write(BinaryWriter writer, UserConfiguration obj) { + writer + ..writeByte(15) + ..writeByte(0) + ..write(obj.audioLanguagePreference) + ..writeByte(1) + ..write(obj.playDefaultAudioTrack) + ..writeByte(2) + ..write(obj.subtitleLanguagePreference) + ..writeByte(3) + ..write(obj.displayMissingEpisodes) + ..writeByte(4) + ..write(obj.groupedFolders) + ..writeByte(5) + ..write(obj.subtitleMode) + ..writeByte(6) + ..write(obj.displayCollectionsView) + ..writeByte(7) + ..write(obj.enableLocalPassword) + ..writeByte(8) + ..write(obj.orderedViews) + ..writeByte(9) + ..write(obj.latestItemsExcludes) + ..writeByte(10) + ..write(obj.myMediaExcludes) + ..writeByte(11) + ..write(obj.hidePlayedInLatest) + ..writeByte(12) + ..write(obj.rememberAudioSelections) + ..writeByte(13) + ..write(obj.rememberSubtitleSelections) + ..writeByte(14) + ..write(obj.enableNextEpisodeAutoPlay); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is UserConfigurationAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class UserPolicyAdapter extends TypeAdapter { + @override + final int typeId = 12; + + @override + UserPolicy read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return UserPolicy( + isAdministrator: fields[0] as bool, + isHidden: fields[1] as bool, + isDisabled: fields[2] as bool, + maxParentalRating: fields[3] as int?, + blockedTags: (fields[4] as List?)?.cast(), + enableUserPreferenceAccess: fields[5] as bool, + accessSchedules: (fields[6] as List?)?.cast(), + blockUnratedItems: (fields[7] as List?)?.cast(), + enableRemoteControlOfOtherUsers: fields[8] as bool, + enableSharedDeviceControl: fields[9] as bool, + enableRemoteAccess: fields[10] as bool, + enableLiveTvManagement: fields[11] as bool, + enableLiveTvAccess: fields[12] as bool, + enableMediaPlayback: fields[13] as bool, + enableAudioPlaybackTranscoding: fields[14] as bool, + enableVideoPlaybackTranscoding: fields[15] as bool, + enablePlaybackRemuxing: fields[16] as bool, + forceRemoteSourceTranscoding: fields[34] as bool?, + enableContentDeletion: fields[17] as bool, + enableContentDeletionFromFolders: (fields[18] as List?)?.cast(), + enableContentDownloading: fields[19] as bool, + enableSyncTranscoding: fields[20] as bool, + enableMediaConversion: fields[21] as bool, + enabledDevices: (fields[22] as List?)?.cast(), + enableAllDevices: fields[23] as bool, + enabledChannels: (fields[24] as List?)?.cast(), + enableAllChannels: fields[25] as bool, + enabledFolders: (fields[26] as List?)?.cast(), + enableAllFolders: fields[27] as bool, + invalidLoginAttemptCount: fields[28] as int, + loginAttemptsBeforeLockout: fields[35] as int?, + maxActiveSessions: fields[36] as int?, + enablePublicSharing: fields[29] as bool, + blockedMediaFolders: (fields[30] as List?)?.cast(), + blockedChannels: (fields[31] as List?)?.cast(), + remoteClientBitrateLimit: fields[32] as int, + authenticationProviderId: fields[33] as String?, + passwordResetProviderId: fields[37] as String?, + syncPlayAccess: fields[38] as String, + ); + } + + @override + void write(BinaryWriter writer, UserPolicy obj) { + writer + ..writeByte(39) + ..writeByte(0) + ..write(obj.isAdministrator) + ..writeByte(1) + ..write(obj.isHidden) + ..writeByte(2) + ..write(obj.isDisabled) + ..writeByte(3) + ..write(obj.maxParentalRating) + ..writeByte(4) + ..write(obj.blockedTags) + ..writeByte(5) + ..write(obj.enableUserPreferenceAccess) + ..writeByte(6) + ..write(obj.accessSchedules) + ..writeByte(7) + ..write(obj.blockUnratedItems) + ..writeByte(8) + ..write(obj.enableRemoteControlOfOtherUsers) + ..writeByte(9) + ..write(obj.enableSharedDeviceControl) + ..writeByte(10) + ..write(obj.enableRemoteAccess) + ..writeByte(11) + ..write(obj.enableLiveTvManagement) + ..writeByte(12) + ..write(obj.enableLiveTvAccess) + ..writeByte(13) + ..write(obj.enableMediaPlayback) + ..writeByte(14) + ..write(obj.enableAudioPlaybackTranscoding) + ..writeByte(15) + ..write(obj.enableVideoPlaybackTranscoding) + ..writeByte(16) + ..write(obj.enablePlaybackRemuxing) + ..writeByte(17) + ..write(obj.enableContentDeletion) + ..writeByte(18) + ..write(obj.enableContentDeletionFromFolders) + ..writeByte(19) + ..write(obj.enableContentDownloading) + ..writeByte(20) + ..write(obj.enableSyncTranscoding) + ..writeByte(21) + ..write(obj.enableMediaConversion) + ..writeByte(22) + ..write(obj.enabledDevices) + ..writeByte(23) + ..write(obj.enableAllDevices) + ..writeByte(24) + ..write(obj.enabledChannels) + ..writeByte(25) + ..write(obj.enableAllChannels) + ..writeByte(26) + ..write(obj.enabledFolders) + ..writeByte(27) + ..write(obj.enableAllFolders) + ..writeByte(28) + ..write(obj.invalidLoginAttemptCount) + ..writeByte(29) + ..write(obj.enablePublicSharing) + ..writeByte(30) + ..write(obj.blockedMediaFolders) + ..writeByte(31) + ..write(obj.blockedChannels) + ..writeByte(32) + ..write(obj.remoteClientBitrateLimit) + ..writeByte(33) + ..write(obj.authenticationProviderId) + ..writeByte(34) + ..write(obj.forceRemoteSourceTranscoding) + ..writeByte(35) + ..write(obj.loginAttemptsBeforeLockout) + ..writeByte(36) + ..write(obj.maxActiveSessions) + ..writeByte(37) + ..write(obj.passwordResetProviderId) + ..writeByte(38) + ..write(obj.syncPlayAccess); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is UserPolicyAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class AccessScheduleAdapter extends TypeAdapter { + @override + final int typeId = 13; + + @override + AccessSchedule read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return AccessSchedule( + id: fields[3] as int, + userId: fields[4] as String, + dayOfWeek: fields[0] as String, + startHour: fields[1] as double, + endHour: fields[2] as double, + ); + } + + @override + void write(BinaryWriter writer, AccessSchedule obj) { + writer + ..writeByte(5) + ..writeByte(0) + ..write(obj.dayOfWeek) + ..writeByte(1) + ..write(obj.startHour) + ..writeByte(2) + ..write(obj.endHour) + ..writeByte(3) + ..write(obj.id) + ..writeByte(4) + ..write(obj.userId); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AccessScheduleAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class AuthenticationResultAdapter extends TypeAdapter { + @override + final int typeId = 7; + + @override + AuthenticationResult read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return AuthenticationResult( + user: fields[0] as UserDto?, + sessionInfo: fields[1] as SessionInfo?, + accessToken: fields[2] as String?, + serverId: fields[3] as String?, + ); + } + + @override + void write(BinaryWriter writer, AuthenticationResult obj) { + writer + ..writeByte(4) + ..writeByte(0) + ..write(obj.user) + ..writeByte(1) + ..write(obj.sessionInfo) + ..writeByte(2) + ..write(obj.accessToken) + ..writeByte(3) + ..write(obj.serverId); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AuthenticationResultAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class SessionInfoAdapter extends TypeAdapter { + @override + final int typeId = 10; + + @override + SessionInfo read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return SessionInfo( + playState: fields[0] as PlayerStateInfo?, + additionalUsers: (fields[1] as List?)?.cast(), + capabilities: fields[2] as ClientCapabilities?, + remoteEndPoint: fields[3] as String?, + playableMediaTypes: (fields[4] as List?)?.cast(), + playlistItemId: fields[5] as String?, + id: fields[6] as String?, + serverId: fields[7] as String?, + userId: fields[8] as String, + userName: fields[9] as String?, + userPrimaryImageTag: fields[10] as String?, + client: fields[11] as String?, + lastActivityDate: fields[12] as String, + deviceName: fields[13] as String?, + deviceType: fields[14] as String?, + nowPlayingItem: fields[15] as BaseItemDto?, + deviceId: fields[16] as String?, + supportedCommands: (fields[17] as List?)?.cast(), + transcodingInfo: fields[18] as TranscodingInfo?, + supportsRemoteControl: fields[19] as bool, + lastPlaybackCheckIn: fields[20] as String?, + fullNowPlayingItem: fields[21] as BaseItem?, + nowViewingItem: fields[22] as BaseItemDto?, + applicationVersion: fields[23] as String?, + isActive: fields[24] as bool, + supportsMediaControl: fields[25] as bool, + nowPlayingQueue: (fields[26] as List?)?.cast(), + hasCustomDeviceName: fields[27] as bool, + ); + } + + @override + void write(BinaryWriter writer, SessionInfo obj) { + writer + ..writeByte(28) + ..writeByte(0) + ..write(obj.playState) + ..writeByte(1) + ..write(obj.additionalUsers) + ..writeByte(2) + ..write(obj.capabilities) + ..writeByte(3) + ..write(obj.remoteEndPoint) + ..writeByte(4) + ..write(obj.playableMediaTypes) + ..writeByte(5) + ..write(obj.playlistItemId) + ..writeByte(6) + ..write(obj.id) + ..writeByte(7) + ..write(obj.serverId) + ..writeByte(8) + ..write(obj.userId) + ..writeByte(9) + ..write(obj.userName) + ..writeByte(10) + ..write(obj.userPrimaryImageTag) + ..writeByte(11) + ..write(obj.client) + ..writeByte(12) + ..write(obj.lastActivityDate) + ..writeByte(13) + ..write(obj.deviceName) + ..writeByte(14) + ..write(obj.deviceType) + ..writeByte(15) + ..write(obj.nowPlayingItem) + ..writeByte(16) + ..write(obj.deviceId) + ..writeByte(17) + ..write(obj.supportedCommands) + ..writeByte(18) + ..write(obj.transcodingInfo) + ..writeByte(19) + ..write(obj.supportsRemoteControl) + ..writeByte(20) + ..write(obj.lastPlaybackCheckIn) + ..writeByte(21) + ..write(obj.fullNowPlayingItem) + ..writeByte(22) + ..write(obj.nowViewingItem) + ..writeByte(23) + ..write(obj.applicationVersion) + ..writeByte(24) + ..write(obj.isActive) + ..writeByte(25) + ..write(obj.supportsMediaControl) + ..writeByte(26) + ..write(obj.nowPlayingQueue) + ..writeByte(27) + ..write(obj.hasCustomDeviceName); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SessionInfoAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class PlayerStateInfoAdapter extends TypeAdapter { + @override + final int typeId = 14; + + @override + PlayerStateInfo read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return PlayerStateInfo( + positionTicks: fields[0] as int?, + canSeek: fields[1] as bool, + isPaused: fields[2] as bool, + isMuted: fields[3] as bool, + volumeLevel: fields[4] as int?, + audioStreamIndex: fields[5] as int?, + subtitleStreamIndex: fields[6] as int?, + mediaSourceId: fields[7] as String?, + playMethod: fields[8] as String?, + repeatMode: fields[9] as String?, + ); + } + + @override + void write(BinaryWriter writer, PlayerStateInfo obj) { + writer + ..writeByte(10) + ..writeByte(0) + ..write(obj.positionTicks) + ..writeByte(1) + ..write(obj.canSeek) + ..writeByte(2) + ..write(obj.isPaused) + ..writeByte(3) + ..write(obj.isMuted) + ..writeByte(4) + ..write(obj.volumeLevel) + ..writeByte(5) + ..write(obj.audioStreamIndex) + ..writeByte(6) + ..write(obj.subtitleStreamIndex) + ..writeByte(7) + ..write(obj.mediaSourceId) + ..writeByte(8) + ..write(obj.playMethod) + ..writeByte(9) + ..write(obj.repeatMode); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PlayerStateInfoAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class SessionUserInfoAdapter extends TypeAdapter { + @override + final int typeId = 15; + + @override + SessionUserInfo read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return SessionUserInfo( + userId: fields[0] as String, + userName: fields[1] as String?, + ); + } + + @override + void write(BinaryWriter writer, SessionUserInfo obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.userId) + ..writeByte(1) + ..write(obj.userName); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SessionUserInfoAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class ClientCapabilitiesAdapter extends TypeAdapter { + @override + final int typeId = 16; + + @override + ClientCapabilities read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return ClientCapabilities( + playableMediaTypes: (fields[0] as List?)?.cast(), + supportedCommands: (fields[1] as List?)?.cast(), + supportsMediaControl: fields[2] as bool?, + supportsPersistentIdentifier: fields[3] as bool?, + supportsSync: fields[4] as bool?, + deviceProfile: fields[5] as DeviceProfile?, + iconUrl: fields[6] as String?, + supportsContentUploading: fields[7] as bool?, + messageCallbackUrl: fields[8] as String?, + appStoreUrl: fields[9] as String?, + ); + } + + @override + void write(BinaryWriter writer, ClientCapabilities obj) { + writer + ..writeByte(10) + ..writeByte(0) + ..write(obj.playableMediaTypes) + ..writeByte(1) + ..write(obj.supportedCommands) + ..writeByte(2) + ..write(obj.supportsMediaControl) + ..writeByte(3) + ..write(obj.supportsPersistentIdentifier) + ..writeByte(4) + ..write(obj.supportsSync) + ..writeByte(5) + ..write(obj.deviceProfile) + ..writeByte(6) + ..write(obj.iconUrl) + ..writeByte(7) + ..write(obj.supportsContentUploading) + ..writeByte(8) + ..write(obj.messageCallbackUrl) + ..writeByte(9) + ..write(obj.appStoreUrl); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ClientCapabilitiesAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class DeviceProfileAdapter extends TypeAdapter { + @override + final int typeId = 17; + + @override + DeviceProfile read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return DeviceProfile( + name: fields[0] as String?, + id: fields[1] as String?, + identification: fields[2] as DeviceIdentification?, + friendlyName: fields[3] as String?, + manufacturer: fields[4] as String?, + manufacturerUrl: fields[5] as String?, + modelName: fields[6] as String?, + modelDescription: fields[7] as String?, + modelNumber: fields[8] as String?, + modelUrl: fields[9] as String?, + serialNumber: fields[10] as String?, + enableAlbumArtInDidl: fields[11] as bool, + enableSingleAlbumArtLimit: fields[12] as bool, + enableSingleSubtitleLimit: fields[13] as bool, + supportedMediaTypes: fields[14] as String?, + userId: fields[15] as String?, + albumArtPn: fields[16] as String?, + maxAlbumArtWidth: fields[17] as int, + maxAlbumArtHeight: fields[18] as int, + maxIconWidth: fields[19] as int?, + maxIconHeight: fields[20] as int?, + maxStreamingBitrate: fields[21] as int?, + maxStaticBitrate: fields[22] as int?, + musicStreamingTranscodingBitrate: fields[23] as int?, + maxStaticMusicBitrate: fields[24] as int?, + sonyAggregationFlags: fields[25] as String?, + protocolInfo: fields[26] as String?, + timelineOffsetSeconds: fields[27] as int, + requiresPlainVideoItems: fields[28] as bool, + requiresPlainFolders: fields[29] as bool, + enableMSMediaReceiverRegistrar: fields[30] as bool, + ignoreTranscodeByteRangeRequests: fields[31] as bool, + xmlRootAttributes: (fields[32] as List?)?.cast(), + directPlayProfiles: (fields[33] as List?)?.cast(), + transcodingProfiles: (fields[34] as List?)?.cast(), + containerProfiles: (fields[35] as List?)?.cast(), + codecProfiles: (fields[36] as List?)?.cast(), + responseProfiles: (fields[37] as List?)?.cast(), + subtitleProfiles: (fields[38] as List?)?.cast(), + ); + } + + @override + void write(BinaryWriter writer, DeviceProfile obj) { + writer + ..writeByte(39) + ..writeByte(0) + ..write(obj.name) + ..writeByte(1) + ..write(obj.id) + ..writeByte(2) + ..write(obj.identification) + ..writeByte(3) + ..write(obj.friendlyName) + ..writeByte(4) + ..write(obj.manufacturer) + ..writeByte(5) + ..write(obj.manufacturerUrl) + ..writeByte(6) + ..write(obj.modelName) + ..writeByte(7) + ..write(obj.modelDescription) + ..writeByte(8) + ..write(obj.modelNumber) + ..writeByte(9) + ..write(obj.modelUrl) + ..writeByte(10) + ..write(obj.serialNumber) + ..writeByte(11) + ..write(obj.enableAlbumArtInDidl) + ..writeByte(12) + ..write(obj.enableSingleAlbumArtLimit) + ..writeByte(13) + ..write(obj.enableSingleSubtitleLimit) + ..writeByte(14) + ..write(obj.supportedMediaTypes) + ..writeByte(15) + ..write(obj.userId) + ..writeByte(16) + ..write(obj.albumArtPn) + ..writeByte(17) + ..write(obj.maxAlbumArtWidth) + ..writeByte(18) + ..write(obj.maxAlbumArtHeight) + ..writeByte(19) + ..write(obj.maxIconWidth) + ..writeByte(20) + ..write(obj.maxIconHeight) + ..writeByte(21) + ..write(obj.maxStreamingBitrate) + ..writeByte(22) + ..write(obj.maxStaticBitrate) + ..writeByte(23) + ..write(obj.musicStreamingTranscodingBitrate) + ..writeByte(24) + ..write(obj.maxStaticMusicBitrate) + ..writeByte(25) + ..write(obj.sonyAggregationFlags) + ..writeByte(26) + ..write(obj.protocolInfo) + ..writeByte(27) + ..write(obj.timelineOffsetSeconds) + ..writeByte(28) + ..write(obj.requiresPlainVideoItems) + ..writeByte(29) + ..write(obj.requiresPlainFolders) + ..writeByte(30) + ..write(obj.enableMSMediaReceiverRegistrar) + ..writeByte(31) + ..write(obj.ignoreTranscodeByteRangeRequests) + ..writeByte(32) + ..write(obj.xmlRootAttributes) + ..writeByte(33) + ..write(obj.directPlayProfiles) + ..writeByte(34) + ..write(obj.transcodingProfiles) + ..writeByte(35) + ..write(obj.containerProfiles) + ..writeByte(36) + ..write(obj.codecProfiles) + ..writeByte(37) + ..write(obj.responseProfiles) + ..writeByte(38) + ..write(obj.subtitleProfiles); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DeviceProfileAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class DeviceIdentificationAdapter extends TypeAdapter { + @override + final int typeId = 18; + + @override + DeviceIdentification read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return DeviceIdentification( + friendlyName: fields[0] as String?, + modelNumber: fields[1] as String?, + serialNumber: fields[2] as String?, + modelName: fields[3] as String?, + modelDescription: fields[4] as String?, + modelUrl: fields[5] as String?, + manufacturer: fields[6] as String?, + manufacturerUrl: fields[7] as String?, + headers: (fields[8] as List?)?.cast(), + ); + } + + @override + void write(BinaryWriter writer, DeviceIdentification obj) { + writer + ..writeByte(9) + ..writeByte(0) + ..write(obj.friendlyName) + ..writeByte(1) + ..write(obj.modelNumber) + ..writeByte(2) + ..write(obj.serialNumber) + ..writeByte(3) + ..write(obj.modelName) + ..writeByte(4) + ..write(obj.modelDescription) + ..writeByte(5) + ..write(obj.modelUrl) + ..writeByte(6) + ..write(obj.manufacturer) + ..writeByte(7) + ..write(obj.manufacturerUrl) + ..writeByte(8) + ..write(obj.headers); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DeviceIdentificationAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class HttpHeaderInfoAdapter extends TypeAdapter { + @override + final int typeId = 19; + + @override + HttpHeaderInfo read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return HttpHeaderInfo( + name: fields[0] as String?, + value: fields[1] as String?, + match: fields[2] as String, + ); + } + + @override + void write(BinaryWriter writer, HttpHeaderInfo obj) { + writer + ..writeByte(3) + ..writeByte(0) + ..write(obj.name) + ..writeByte(1) + ..write(obj.value) + ..writeByte(2) + ..write(obj.match); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is HttpHeaderInfoAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class XmlAttributeAdapter extends TypeAdapter { + @override + final int typeId = 20; + + @override + XmlAttribute read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return XmlAttribute( + name: fields[0] as String?, + value: fields[1] as String?, + ); + } + + @override + void write(BinaryWriter writer, XmlAttribute obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.name) + ..writeByte(1) + ..write(obj.value); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is XmlAttributeAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class DirectPlayProfileAdapter extends TypeAdapter { + @override + final int typeId = 21; + + @override + DirectPlayProfile read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return DirectPlayProfile( + container: fields[0] as String?, + audioCodec: fields[1] as String?, + videoCodec: fields[2] as String?, + type: fields[3] as String, + ); + } + + @override + void write(BinaryWriter writer, DirectPlayProfile obj) { + writer + ..writeByte(4) + ..writeByte(0) + ..write(obj.container) + ..writeByte(1) + ..write(obj.audioCodec) + ..writeByte(2) + ..write(obj.videoCodec) + ..writeByte(3) + ..write(obj.type); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DirectPlayProfileAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class TranscodingProfileAdapter extends TypeAdapter { + @override + final int typeId = 22; + + @override + TranscodingProfile read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return TranscodingProfile( + container: fields[0] as String?, + type: fields[1] as String, + videoCodec: fields[2] as String?, + audioCodec: fields[3] as String?, + protocol: fields[4] as String?, + estimateContentLength: fields[5] as bool, + enableMpegtsM2TsMode: fields[6] as bool, + transcodeSeekInfo: fields[7] as String, + copyTimestamps: fields[8] as bool, + context: fields[9] as String, + maxAudioChannels: fields[10] as String?, + minSegments: fields[11] as int, + segmentLength: fields[12] as int, + breakOnNonKeyFrames: fields[13] as bool, + enableSubtitlesInManifest: fields[14] as bool, + ); + } + + @override + void write(BinaryWriter writer, TranscodingProfile obj) { + writer + ..writeByte(15) + ..writeByte(0) + ..write(obj.container) + ..writeByte(1) + ..write(obj.type) + ..writeByte(2) + ..write(obj.videoCodec) + ..writeByte(3) + ..write(obj.audioCodec) + ..writeByte(4) + ..write(obj.protocol) + ..writeByte(5) + ..write(obj.estimateContentLength) + ..writeByte(6) + ..write(obj.enableMpegtsM2TsMode) + ..writeByte(7) + ..write(obj.transcodeSeekInfo) + ..writeByte(8) + ..write(obj.copyTimestamps) + ..writeByte(9) + ..write(obj.context) + ..writeByte(10) + ..write(obj.maxAudioChannels) + ..writeByte(11) + ..write(obj.minSegments) + ..writeByte(12) + ..write(obj.segmentLength) + ..writeByte(13) + ..write(obj.breakOnNonKeyFrames) + ..writeByte(14) + ..write(obj.enableSubtitlesInManifest); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TranscodingProfileAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class ContainerProfileAdapter extends TypeAdapter { + @override + final int typeId = 23; + + @override + ContainerProfile read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return ContainerProfile( + type: fields[0] as String, + conditions: (fields[1] as List?)?.cast(), + container: fields[2] as String?, + ); + } + + @override + void write(BinaryWriter writer, ContainerProfile obj) { + writer + ..writeByte(3) + ..writeByte(0) + ..write(obj.type) + ..writeByte(1) + ..write(obj.conditions) + ..writeByte(2) + ..write(obj.container); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ContainerProfileAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class ProfileConditionAdapter extends TypeAdapter { + @override + final int typeId = 24; + + @override + ProfileCondition read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return ProfileCondition( + condition: fields[0] as String, + property: fields[1] as String, + value: fields[2] as String?, + isRequired: fields[3] as bool, + ); + } + + @override + void write(BinaryWriter writer, ProfileCondition obj) { + writer + ..writeByte(4) + ..writeByte(0) + ..write(obj.condition) + ..writeByte(1) + ..write(obj.property) + ..writeByte(2) + ..write(obj.value) + ..writeByte(3) + ..write(obj.isRequired); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProfileConditionAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class CodecProfileAdapter extends TypeAdapter { + @override + final int typeId = 25; + + @override + CodecProfile read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return CodecProfile( + type: fields[0] as String, + conditions: (fields[1] as List?)?.cast(), + applyConditions: (fields[2] as List?)?.cast(), + codec: fields[3] as String?, + container: fields[4] as String?, + ); + } + + @override + void write(BinaryWriter writer, CodecProfile obj) { + writer + ..writeByte(5) + ..writeByte(0) + ..write(obj.type) + ..writeByte(1) + ..write(obj.conditions) + ..writeByte(2) + ..write(obj.applyConditions) + ..writeByte(3) + ..write(obj.codec) + ..writeByte(4) + ..write(obj.container); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CodecProfileAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class ResponseProfileAdapter extends TypeAdapter { + @override + final int typeId = 26; + + @override + ResponseProfile read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return ResponseProfile( + container: fields[0] as String?, + audioCodec: fields[1] as String?, + videoCodec: fields[2] as String?, + type: fields[3] as String, + orgPn: fields[4] as String?, + mimeType: fields[5] as String?, + conditions: (fields[6] as List?)?.cast(), + ); + } + + @override + void write(BinaryWriter writer, ResponseProfile obj) { + writer + ..writeByte(7) + ..writeByte(0) + ..write(obj.container) + ..writeByte(1) + ..write(obj.audioCodec) + ..writeByte(2) + ..write(obj.videoCodec) + ..writeByte(3) + ..write(obj.type) + ..writeByte(4) + ..write(obj.orgPn) + ..writeByte(5) + ..write(obj.mimeType) + ..writeByte(6) + ..write(obj.conditions); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ResponseProfileAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class SubtitleProfileAdapter extends TypeAdapter { + @override + final int typeId = 27; + + @override + SubtitleProfile read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return SubtitleProfile( + format: fields[0] as String?, + method: fields[1] as String, + didlMode: fields[2] as String?, + language: fields[3] as String?, + container: fields[4] as String?, + ); + } + + @override + void write(BinaryWriter writer, SubtitleProfile obj) { + writer + ..writeByte(5) + ..writeByte(0) + ..write(obj.format) + ..writeByte(1) + ..write(obj.method) + ..writeByte(2) + ..write(obj.didlMode) + ..writeByte(3) + ..write(obj.language) + ..writeByte(4) + ..write(obj.container); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SubtitleProfileAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class BaseItemDtoAdapter extends TypeAdapter { + @override + final int typeId = 0; + + @override + BaseItemDto read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return BaseItemDto( + name: fields[0] as String?, + originalTitle: fields[1] as String?, + serverId: fields[2] as String?, + id: fields[3] as String, + etag: fields[4] as String?, + playlistItemId: fields[5] as String?, + dateCreated: fields[6] as String?, + extraType: fields[7] as String?, + airsBeforeSeasonNumber: fields[8] as int?, + airsAfterSeasonNumber: fields[9] as int?, + airsBeforeEpisodeNumber: fields[10] as int?, + canDelete: fields[11] as bool?, + canDownload: fields[12] as bool?, + hasSubtitles: fields[13] as bool?, + preferredMetadataLanguage: fields[14] as String?, + preferredMetadataCountryCode: fields[15] as String?, + supportsSync: fields[16] as bool?, + container: fields[17] as String?, + sortName: fields[18] as String?, + forcedSortName: fields[19] as String?, + video3DFormat: fields[20] as String?, + premiereDate: fields[21] as String?, + externalUrls: (fields[22] as List?)?.cast(), + mediaSources: (fields[23] as List?)?.cast(), + criticRating: fields[24] as double?, + productionLocations: (fields[25] as List?)?.cast(), + path: fields[26] as String?, + officialRating: fields[27] as String?, + customRating: fields[28] as String?, + channelId: fields[29] as String?, + channelName: fields[30] as String?, + overview: fields[31] as String?, + taglines: (fields[32] as List?)?.cast(), + genres: (fields[33] as List?)?.cast(), + communityRating: fields[34] as double?, + runTimeTicks: fields[35] as int?, + playAccess: fields[36] as String?, + aspectRatio: fields[37] as String?, + productionYear: fields[38] as int?, + number: fields[39] as String?, + channelNumber: fields[40] as String?, + indexNumber: fields[41] as int?, + indexNumberEnd: fields[42] as int?, + parentIndexNumber: fields[43] as int?, + remoteTrailers: (fields[44] as List?)?.cast(), + providerIds: (fields[45] as Map?)?.cast(), + isFolder: fields[46] as bool?, + parentId: fields[47] as String?, + type: fields[48] as String?, + people: (fields[49] as List?)?.cast(), + studios: (fields[50] as List?)?.cast(), + genreItems: (fields[51] as List?)?.cast(), + parentLogoItemId: fields[52] as String?, + parentBackdropItemId: fields[53] as String?, + parentBackdropImageTags: (fields[54] as List?)?.cast(), + localTrailerCount: fields[55] as int?, + userData: fields[56] as UserItemDataDto?, + recursiveItemCount: fields[57] as int?, + childCount: fields[58] as int?, + seriesName: fields[59] as String?, + seriesId: fields[60] as String?, + seasonId: fields[61] as String?, + specialFeatureCount: fields[62] as int?, + displayPreferencesId: fields[63] as String?, + status: fields[64] as String?, + airTime: fields[65] as String?, + airDays: (fields[66] as List?)?.cast(), + tags: (fields[67] as List?)?.cast(), + primaryImageAspectRatio: fields[68] as double?, + artists: (fields[69] as List?)?.cast(), + artistItems: (fields[70] as List?)?.cast(), + album: fields[71] as String?, + collectionType: fields[72] as String?, + displayOrder: fields[73] as String?, + albumId: fields[74] as String?, + albumPrimaryImageTag: fields[75] as String?, + seriesPrimaryImageTag: fields[76] as String?, + albumArtist: fields[77] as String?, + albumArtists: (fields[78] as List?)?.cast(), + seasonName: fields[79] as String?, + mediaStreams: (fields[80] as List?)?.cast(), + partCount: fields[81] as int?, + imageTags: (fields[82] as Map?)?.cast(), + backdropImageTags: (fields[83] as List?)?.cast(), + parentLogoImageTag: fields[84] as String?, + parentArtItemId: fields[85] as String?, + parentArtImageTag: fields[86] as String?, + seriesThumbImageTag: fields[87] as String?, + seriesStudio: fields[88] as String?, + parentThumbItemId: fields[89] as String?, + parentThumbImageTag: fields[90] as String?, + parentPrimaryImageItemId: fields[91] as String?, + parentPrimaryImageTag: fields[92] as String?, + chapters: (fields[93] as List?)?.cast(), + locationType: fields[94] as String?, + mediaType: fields[95] as String?, + endDate: fields[96] as String?, + lockedFields: (fields[97] as List?)?.cast(), + lockData: fields[98] as bool?, + width: fields[99] as int?, + height: fields[100] as int?, + cameraMake: fields[101] as String?, + cameraModel: fields[102] as String?, + software: fields[103] as String?, + exposureTime: fields[104] as double?, + focalLength: fields[105] as double?, + imageOrientation: fields[106] as String?, + aperture: fields[107] as double?, + shutterSpeed: fields[108] as double?, + latitude: fields[109] as double?, + longitude: fields[110] as double?, + altitude: fields[111] as double?, + isoSpeedRating: fields[112] as int?, + seriesTimerId: fields[113] as String?, + channelPrimaryImageTag: fields[114] as String?, + startDate: fields[115] as String?, + completionPercentage: fields[116] as double?, + isRepeat: fields[117] as bool?, + episodeTitle: fields[118] as String?, + isMovie: fields[119] as bool?, + isSports: fields[120] as bool?, + isSeries: fields[121] as bool?, + isLive: fields[122] as bool?, + isNews: fields[123] as bool?, + isKids: fields[124] as bool?, + isPremiere: fields[125] as bool?, + timerId: fields[126] as String?, + currentProgram: fields[127] as dynamic, + movieCount: fields[128] as int?, + seriesCount: fields[129] as int?, + albumCount: fields[130] as int?, + songCount: fields[131] as int?, + musicVideoCount: fields[132] as int?, + sourceType: fields[133] as String?, + dateLastMediaAdded: fields[134] as String?, + enableMediaSourceDisplay: fields[135] as bool?, + cumulativeRunTimeTicks: fields[136] as int?, + isPlaceHolder: fields[137] as bool?, + isHD: fields[138] as bool?, + videoType: fields[139] as String?, + mediaSourceCount: fields[140] as int?, + screenshotImageTags: (fields[141] as List?)?.cast(), + imageBlurHashes: fields[142] as ImageBlurHashes?, + isoType: fields[143] as String?, + trailerCount: fields[144] as int?, + programCount: fields[145] as int?, + episodeCount: fields[146] as int?, + artistCount: fields[147] as int?, + programId: fields[148] as String?, + channelType: fields[149] as String?, + audio: fields[150] as String?, + ); + } + + @override + void write(BinaryWriter writer, BaseItemDto obj) { + writer + ..writeByte(151) + ..writeByte(0) + ..write(obj.name) + ..writeByte(1) + ..write(obj.originalTitle) + ..writeByte(2) + ..write(obj.serverId) + ..writeByte(3) + ..write(obj.id) + ..writeByte(4) + ..write(obj.etag) + ..writeByte(5) + ..write(obj.playlistItemId) + ..writeByte(6) + ..write(obj.dateCreated) + ..writeByte(7) + ..write(obj.extraType) + ..writeByte(8) + ..write(obj.airsBeforeSeasonNumber) + ..writeByte(9) + ..write(obj.airsAfterSeasonNumber) + ..writeByte(10) + ..write(obj.airsBeforeEpisodeNumber) + ..writeByte(11) + ..write(obj.canDelete) + ..writeByte(12) + ..write(obj.canDownload) + ..writeByte(13) + ..write(obj.hasSubtitles) + ..writeByte(14) + ..write(obj.preferredMetadataLanguage) + ..writeByte(15) + ..write(obj.preferredMetadataCountryCode) + ..writeByte(16) + ..write(obj.supportsSync) + ..writeByte(17) + ..write(obj.container) + ..writeByte(18) + ..write(obj.sortName) + ..writeByte(19) + ..write(obj.forcedSortName) + ..writeByte(20) + ..write(obj.video3DFormat) + ..writeByte(21) + ..write(obj.premiereDate) + ..writeByte(22) + ..write(obj.externalUrls) + ..writeByte(23) + ..write(obj.mediaSources) + ..writeByte(24) + ..write(obj.criticRating) + ..writeByte(25) + ..write(obj.productionLocations) + ..writeByte(26) + ..write(obj.path) + ..writeByte(27) + ..write(obj.officialRating) + ..writeByte(28) + ..write(obj.customRating) + ..writeByte(29) + ..write(obj.channelId) + ..writeByte(30) + ..write(obj.channelName) + ..writeByte(31) + ..write(obj.overview) + ..writeByte(32) + ..write(obj.taglines) + ..writeByte(33) + ..write(obj.genres) + ..writeByte(34) + ..write(obj.communityRating) + ..writeByte(35) + ..write(obj.runTimeTicks) + ..writeByte(36) + ..write(obj.playAccess) + ..writeByte(37) + ..write(obj.aspectRatio) + ..writeByte(38) + ..write(obj.productionYear) + ..writeByte(39) + ..write(obj.number) + ..writeByte(40) + ..write(obj.channelNumber) + ..writeByte(41) + ..write(obj.indexNumber) + ..writeByte(42) + ..write(obj.indexNumberEnd) + ..writeByte(43) + ..write(obj.parentIndexNumber) + ..writeByte(44) + ..write(obj.remoteTrailers) + ..writeByte(45) + ..write(obj.providerIds) + ..writeByte(46) + ..write(obj.isFolder) + ..writeByte(47) + ..write(obj.parentId) + ..writeByte(48) + ..write(obj.type) + ..writeByte(49) + ..write(obj.people) + ..writeByte(50) + ..write(obj.studios) + ..writeByte(51) + ..write(obj.genreItems) + ..writeByte(52) + ..write(obj.parentLogoItemId) + ..writeByte(53) + ..write(obj.parentBackdropItemId) + ..writeByte(54) + ..write(obj.parentBackdropImageTags) + ..writeByte(55) + ..write(obj.localTrailerCount) + ..writeByte(56) + ..write(obj.userData) + ..writeByte(57) + ..write(obj.recursiveItemCount) + ..writeByte(58) + ..write(obj.childCount) + ..writeByte(59) + ..write(obj.seriesName) + ..writeByte(60) + ..write(obj.seriesId) + ..writeByte(61) + ..write(obj.seasonId) + ..writeByte(62) + ..write(obj.specialFeatureCount) + ..writeByte(63) + ..write(obj.displayPreferencesId) + ..writeByte(64) + ..write(obj.status) + ..writeByte(65) + ..write(obj.airTime) + ..writeByte(66) + ..write(obj.airDays) + ..writeByte(67) + ..write(obj.tags) + ..writeByte(68) + ..write(obj.primaryImageAspectRatio) + ..writeByte(69) + ..write(obj.artists) + ..writeByte(70) + ..write(obj.artistItems) + ..writeByte(71) + ..write(obj.album) + ..writeByte(72) + ..write(obj.collectionType) + ..writeByte(73) + ..write(obj.displayOrder) + ..writeByte(74) + ..write(obj.albumId) + ..writeByte(75) + ..write(obj.albumPrimaryImageTag) + ..writeByte(76) + ..write(obj.seriesPrimaryImageTag) + ..writeByte(77) + ..write(obj.albumArtist) + ..writeByte(78) + ..write(obj.albumArtists) + ..writeByte(79) + ..write(obj.seasonName) + ..writeByte(80) + ..write(obj.mediaStreams) + ..writeByte(81) + ..write(obj.partCount) + ..writeByte(82) + ..write(obj.imageTags) + ..writeByte(83) + ..write(obj.backdropImageTags) + ..writeByte(84) + ..write(obj.parentLogoImageTag) + ..writeByte(85) + ..write(obj.parentArtItemId) + ..writeByte(86) + ..write(obj.parentArtImageTag) + ..writeByte(87) + ..write(obj.seriesThumbImageTag) + ..writeByte(88) + ..write(obj.seriesStudio) + ..writeByte(89) + ..write(obj.parentThumbItemId) + ..writeByte(90) + ..write(obj.parentThumbImageTag) + ..writeByte(91) + ..write(obj.parentPrimaryImageItemId) + ..writeByte(92) + ..write(obj.parentPrimaryImageTag) + ..writeByte(93) + ..write(obj.chapters) + ..writeByte(94) + ..write(obj.locationType) + ..writeByte(95) + ..write(obj.mediaType) + ..writeByte(96) + ..write(obj.endDate) + ..writeByte(97) + ..write(obj.lockedFields) + ..writeByte(98) + ..write(obj.lockData) + ..writeByte(99) + ..write(obj.width) + ..writeByte(100) + ..write(obj.height) + ..writeByte(101) + ..write(obj.cameraMake) + ..writeByte(102) + ..write(obj.cameraModel) + ..writeByte(103) + ..write(obj.software) + ..writeByte(104) + ..write(obj.exposureTime) + ..writeByte(105) + ..write(obj.focalLength) + ..writeByte(106) + ..write(obj.imageOrientation) + ..writeByte(107) + ..write(obj.aperture) + ..writeByte(108) + ..write(obj.shutterSpeed) + ..writeByte(109) + ..write(obj.latitude) + ..writeByte(110) + ..write(obj.longitude) + ..writeByte(111) + ..write(obj.altitude) + ..writeByte(112) + ..write(obj.isoSpeedRating) + ..writeByte(113) + ..write(obj.seriesTimerId) + ..writeByte(114) + ..write(obj.channelPrimaryImageTag) + ..writeByte(115) + ..write(obj.startDate) + ..writeByte(116) + ..write(obj.completionPercentage) + ..writeByte(117) + ..write(obj.isRepeat) + ..writeByte(118) + ..write(obj.episodeTitle) + ..writeByte(119) + ..write(obj.isMovie) + ..writeByte(120) + ..write(obj.isSports) + ..writeByte(121) + ..write(obj.isSeries) + ..writeByte(122) + ..write(obj.isLive) + ..writeByte(123) + ..write(obj.isNews) + ..writeByte(124) + ..write(obj.isKids) + ..writeByte(125) + ..write(obj.isPremiere) + ..writeByte(126) + ..write(obj.timerId) + ..writeByte(127) + ..write(obj.currentProgram) + ..writeByte(128) + ..write(obj.movieCount) + ..writeByte(129) + ..write(obj.seriesCount) + ..writeByte(130) + ..write(obj.albumCount) + ..writeByte(131) + ..write(obj.songCount) + ..writeByte(132) + ..write(obj.musicVideoCount) + ..writeByte(133) + ..write(obj.sourceType) + ..writeByte(134) + ..write(obj.dateLastMediaAdded) + ..writeByte(135) + ..write(obj.enableMediaSourceDisplay) + ..writeByte(136) + ..write(obj.cumulativeRunTimeTicks) + ..writeByte(137) + ..write(obj.isPlaceHolder) + ..writeByte(138) + ..write(obj.isHD) + ..writeByte(139) + ..write(obj.videoType) + ..writeByte(140) + ..write(obj.mediaSourceCount) + ..writeByte(141) + ..write(obj.screenshotImageTags) + ..writeByte(142) + ..write(obj.imageBlurHashes) + ..writeByte(143) + ..write(obj.isoType) + ..writeByte(144) + ..write(obj.trailerCount) + ..writeByte(145) + ..write(obj.programCount) + ..writeByte(146) + ..write(obj.episodeCount) + ..writeByte(147) + ..write(obj.artistCount) + ..writeByte(148) + ..write(obj.programId) + ..writeByte(149) + ..write(obj.channelType) + ..writeByte(150) + ..write(obj.audio); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BaseItemDtoAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class ExternalUrlAdapter extends TypeAdapter { + @override + final int typeId = 29; + + @override + ExternalUrl read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return ExternalUrl( + name: fields[0] as String?, + url: fields[1] as String?, + ); + } + + @override + void write(BinaryWriter writer, ExternalUrl obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.name) + ..writeByte(1) + ..write(obj.url); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ExternalUrlAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class MediaSourceInfoAdapter extends TypeAdapter { + @override + final int typeId = 5; + + @override + MediaSourceInfo read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return MediaSourceInfo( + protocol: fields[0] as String, + id: fields[1] as String?, + path: fields[2] as String?, + encoderPath: fields[3] as String?, + encoderProtocol: fields[4] as String?, + type: fields[5] as String, + container: fields[6] as String?, + size: fields[7] as int?, + name: fields[8] as String?, + isRemote: fields[9] as bool, + runTimeTicks: fields[10] as int?, + supportsTranscoding: fields[11] as bool, + supportsDirectStream: fields[12] as bool, + supportsDirectPlay: fields[13] as bool, + isInfiniteStream: fields[14] as bool, + requiresOpening: fields[15] as bool, + openToken: fields[16] as String?, + requiresClosing: fields[17] as bool, + liveStreamId: fields[18] as String?, + bufferMs: fields[19] as int?, + requiresLooping: fields[20] as bool, + supportsProbing: fields[21] as bool, + video3DFormat: fields[22] as String?, + mediaStreams: (fields[23] as List).cast(), + formats: (fields[24] as List?)?.cast(), + bitrate: fields[25] as int?, + timestamp: fields[26] as String?, + requiredHttpHeaders: (fields[27] as Map?)?.cast(), + transcodingUrl: fields[28] as String?, + transcodingSubProtocol: fields[29] as String?, + transcodingContainer: fields[30] as String?, + analyzeDurationMs: fields[31] as int?, + readAtNativeFramerate: fields[32] as bool, + defaultAudioStreamIndex: fields[33] as int?, + defaultSubtitleStreamIndex: fields[34] as int?, + etag: fields[35] as String?, + ignoreDts: fields[36] as bool, + ignoreIndex: fields[37] as bool, + genPtsInput: fields[38] as bool, + videoType: fields[39] as String?, + isoType: fields[40] as String?, + mediaAttachments: (fields[41] as List?)?.cast(), + ); + } + + @override + void write(BinaryWriter writer, MediaSourceInfo obj) { + writer + ..writeByte(42) + ..writeByte(0) + ..write(obj.protocol) + ..writeByte(1) + ..write(obj.id) + ..writeByte(2) + ..write(obj.path) + ..writeByte(3) + ..write(obj.encoderPath) + ..writeByte(4) + ..write(obj.encoderProtocol) + ..writeByte(5) + ..write(obj.type) + ..writeByte(6) + ..write(obj.container) + ..writeByte(7) + ..write(obj.size) + ..writeByte(8) + ..write(obj.name) + ..writeByte(9) + ..write(obj.isRemote) + ..writeByte(10) + ..write(obj.runTimeTicks) + ..writeByte(11) + ..write(obj.supportsTranscoding) + ..writeByte(12) + ..write(obj.supportsDirectStream) + ..writeByte(13) + ..write(obj.supportsDirectPlay) + ..writeByte(14) + ..write(obj.isInfiniteStream) + ..writeByte(15) + ..write(obj.requiresOpening) + ..writeByte(16) + ..write(obj.openToken) + ..writeByte(17) + ..write(obj.requiresClosing) + ..writeByte(18) + ..write(obj.liveStreamId) + ..writeByte(19) + ..write(obj.bufferMs) + ..writeByte(20) + ..write(obj.requiresLooping) + ..writeByte(21) + ..write(obj.supportsProbing) + ..writeByte(22) + ..write(obj.video3DFormat) + ..writeByte(23) + ..write(obj.mediaStreams) + ..writeByte(24) + ..write(obj.formats) + ..writeByte(25) + ..write(obj.bitrate) + ..writeByte(26) + ..write(obj.timestamp) + ..writeByte(27) + ..write(obj.requiredHttpHeaders) + ..writeByte(28) + ..write(obj.transcodingUrl) + ..writeByte(29) + ..write(obj.transcodingSubProtocol) + ..writeByte(30) + ..write(obj.transcodingContainer) + ..writeByte(31) + ..write(obj.analyzeDurationMs) + ..writeByte(32) + ..write(obj.readAtNativeFramerate) + ..writeByte(33) + ..write(obj.defaultAudioStreamIndex) + ..writeByte(34) + ..write(obj.defaultSubtitleStreamIndex) + ..writeByte(35) + ..write(obj.etag) + ..writeByte(36) + ..write(obj.ignoreDts) + ..writeByte(37) + ..write(obj.ignoreIndex) + ..writeByte(38) + ..write(obj.genPtsInput) + ..writeByte(39) + ..write(obj.videoType) + ..writeByte(40) + ..write(obj.isoType) + ..writeByte(41) + ..write(obj.mediaAttachments); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MediaSourceInfoAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class MediaStreamAdapter extends TypeAdapter { + @override + final int typeId = 6; + + @override + MediaStream read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return MediaStream( + codec: fields[0] as String?, + codecTag: fields[1] as String?, + language: fields[2] as String?, + colorTransfer: fields[3] as String?, + colorPrimaries: fields[4] as String?, + colorSpace: fields[5] as String?, + comment: fields[6] as String?, + timeBase: fields[7] as String?, + codecTimeBase: fields[8] as String?, + title: fields[9] as String?, + videoRange: fields[10] as String?, + displayTitle: fields[11] as String?, + nalLengthSize: fields[12] as String?, + isInterlaced: fields[13] as bool, + isAVC: fields[14] as bool?, + channelLayout: fields[15] as String?, + bitRate: fields[16] as int?, + bitDepth: fields[17] as int?, + refFrames: fields[18] as int?, + packetLength: fields[19] as int?, + channels: fields[20] as int?, + sampleRate: fields[21] as int?, + isDefault: fields[22] as bool, + isForced: fields[23] as bool, + height: fields[24] as int?, + width: fields[25] as int?, + averageFrameRate: fields[26] as double?, + realFrameRate: fields[27] as double?, + profile: fields[28] as String?, + type: fields[29] as String, + aspectRatio: fields[30] as String?, + index: fields[31] as int, + score: fields[32] as int?, + isExternal: fields[33] as bool, + deliveryMethod: fields[34] as String?, + deliveryUrl: fields[35] as String?, + isExternalUrl: fields[36] as bool?, + isTextSubtitleStream: fields[37] as bool, + supportsExternalStream: fields[38] as bool, + path: fields[39] as String?, + pixelFormat: fields[40] as String?, + level: fields[41] as double?, + isAnamorphic: fields[42] as bool?, + ) + ..colorRange = fields[43] as String? + ..localizedUndefined = fields[44] as String? + ..localizedDefault = fields[45] as String? + ..localizedForced = fields[46] as String?; + } + + @override + void write(BinaryWriter writer, MediaStream obj) { + writer + ..writeByte(47) + ..writeByte(0) + ..write(obj.codec) + ..writeByte(1) + ..write(obj.codecTag) + ..writeByte(2) + ..write(obj.language) + ..writeByte(3) + ..write(obj.colorTransfer) + ..writeByte(4) + ..write(obj.colorPrimaries) + ..writeByte(5) + ..write(obj.colorSpace) + ..writeByte(6) + ..write(obj.comment) + ..writeByte(7) + ..write(obj.timeBase) + ..writeByte(8) + ..write(obj.codecTimeBase) + ..writeByte(9) + ..write(obj.title) + ..writeByte(10) + ..write(obj.videoRange) + ..writeByte(11) + ..write(obj.displayTitle) + ..writeByte(12) + ..write(obj.nalLengthSize) + ..writeByte(13) + ..write(obj.isInterlaced) + ..writeByte(14) + ..write(obj.isAVC) + ..writeByte(15) + ..write(obj.channelLayout) + ..writeByte(16) + ..write(obj.bitRate) + ..writeByte(17) + ..write(obj.bitDepth) + ..writeByte(18) + ..write(obj.refFrames) + ..writeByte(19) + ..write(obj.packetLength) + ..writeByte(20) + ..write(obj.channels) + ..writeByte(21) + ..write(obj.sampleRate) + ..writeByte(22) + ..write(obj.isDefault) + ..writeByte(23) + ..write(obj.isForced) + ..writeByte(24) + ..write(obj.height) + ..writeByte(25) + ..write(obj.width) + ..writeByte(26) + ..write(obj.averageFrameRate) + ..writeByte(27) + ..write(obj.realFrameRate) + ..writeByte(28) + ..write(obj.profile) + ..writeByte(29) + ..write(obj.type) + ..writeByte(30) + ..write(obj.aspectRatio) + ..writeByte(31) + ..write(obj.index) + ..writeByte(32) + ..write(obj.score) + ..writeByte(33) + ..write(obj.isExternal) + ..writeByte(34) + ..write(obj.deliveryMethod) + ..writeByte(35) + ..write(obj.deliveryUrl) + ..writeByte(36) + ..write(obj.isExternalUrl) + ..writeByte(37) + ..write(obj.isTextSubtitleStream) + ..writeByte(38) + ..write(obj.supportsExternalStream) + ..writeByte(39) + ..write(obj.path) + ..writeByte(40) + ..write(obj.pixelFormat) + ..writeByte(41) + ..write(obj.level) + ..writeByte(42) + ..write(obj.isAnamorphic) + ..writeByte(43) + ..write(obj.colorRange) + ..writeByte(44) + ..write(obj.localizedUndefined) + ..writeByte(45) + ..write(obj.localizedDefault) + ..writeByte(46) + ..write(obj.localizedForced); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MediaStreamAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class NameLongIdPairAdapter extends TypeAdapter { + @override + final int typeId = 30; + + @override + NameLongIdPair read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return NameLongIdPair( + name: fields[0] as String?, + id: fields[1] as String, + ); + } + + @override + void write(BinaryWriter writer, NameLongIdPair obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.name) + ..writeByte(1) + ..write(obj.id); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is NameLongIdPairAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class UserItemDataDtoAdapter extends TypeAdapter { + @override + final int typeId = 1; + + @override + UserItemDataDto read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return UserItemDataDto( + rating: fields[0] as double?, + playedPercentage: fields[1] as double?, + unplayedItemCount: fields[2] as int?, + playbackPositionTicks: fields[3] as int, + playCount: fields[4] as int, + isFavorite: fields[5] as bool, + likes: fields[6] as bool?, + lastPlayedDate: fields[7] as String?, + played: fields[8] as bool, + key: fields[9] as String?, + itemId: fields[10] as String?, + ); + } + + @override + void write(BinaryWriter writer, UserItemDataDto obj) { + writer + ..writeByte(11) + ..writeByte(0) + ..write(obj.rating) + ..writeByte(1) + ..write(obj.playedPercentage) + ..writeByte(2) + ..write(obj.unplayedItemCount) + ..writeByte(3) + ..write(obj.playbackPositionTicks) + ..writeByte(4) + ..write(obj.playCount) + ..writeByte(5) + ..write(obj.isFavorite) + ..writeByte(6) + ..write(obj.likes) + ..writeByte(7) + ..write(obj.lastPlayedDate) + ..writeByte(8) + ..write(obj.played) + ..writeByte(9) + ..write(obj.key) + ..writeByte(10) + ..write(obj.itemId); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is UserItemDataDtoAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class NameIdPairAdapter extends TypeAdapter { + @override + final int typeId = 2; + + @override + NameIdPair read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return NameIdPair( + name: fields[0] as String?, + id: fields[1] as String, + ); + } + + @override + void write(BinaryWriter writer, NameIdPair obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.name) + ..writeByte(1) + ..write(obj.id); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is NameIdPairAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class ImageBlurHashesAdapter extends TypeAdapter { + @override + final int typeId = 32; + + @override + ImageBlurHashes read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return ImageBlurHashes( + primary: (fields[0] as Map?)?.cast(), + art: (fields[1] as Map?)?.cast(), + backdrop: (fields[2] as Map?)?.cast(), + banner: (fields[3] as Map?)?.cast(), + logo: (fields[4] as Map?)?.cast(), + thumb: (fields[5] as Map?)?.cast(), + disc: (fields[6] as Map?)?.cast(), + box: (fields[7] as Map?)?.cast(), + screenshot: (fields[8] as Map?)?.cast(), + menu: (fields[9] as Map?)?.cast(), + chapter: (fields[10] as Map?)?.cast(), + boxRear: (fields[11] as Map?)?.cast(), + profile: (fields[12] as Map?)?.cast(), + ); + } + + @override + void write(BinaryWriter writer, ImageBlurHashes obj) { + writer + ..writeByte(13) + ..writeByte(0) + ..write(obj.primary) + ..writeByte(1) + ..write(obj.art) + ..writeByte(2) + ..write(obj.backdrop) + ..writeByte(3) + ..write(obj.banner) + ..writeByte(4) + ..write(obj.logo) + ..writeByte(5) + ..write(obj.thumb) + ..writeByte(6) + ..write(obj.disc) + ..writeByte(7) + ..write(obj.box) + ..writeByte(8) + ..write(obj.screenshot) + ..writeByte(9) + ..write(obj.menu) + ..writeByte(10) + ..write(obj.chapter) + ..writeByte(11) + ..write(obj.boxRear) + ..writeByte(12) + ..write(obj.profile); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ImageBlurHashesAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class MediaAttachmentAdapter extends TypeAdapter { + @override + final int typeId = 33; + + @override + MediaAttachment read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return MediaAttachment( + codec: fields[0] as String?, + codecTag: fields[1] as String?, + comment: fields[2] as String?, + index: fields[3] as int, + fileName: fields[4] as String?, + mimeType: fields[5] as String?, + deliveryUrl: fields[6] as String?, + ); + } + + @override + void write(BinaryWriter writer, MediaAttachment obj) { + writer + ..writeByte(7) + ..writeByte(0) + ..write(obj.codec) + ..writeByte(1) + ..write(obj.codecTag) + ..writeByte(2) + ..write(obj.comment) + ..writeByte(3) + ..write(obj.index) + ..writeByte(4) + ..write(obj.fileName) + ..writeByte(5) + ..write(obj.mimeType) + ..writeByte(6) + ..write(obj.deliveryUrl); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MediaAttachmentAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class BaseItemAdapter extends TypeAdapter { + @override + final int typeId = 34; + + @override + BaseItem read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return BaseItem( + size: fields[0] as int?, + container: fields[1] as String?, + dateLastSaved: fields[2] as String?, + remoteTrailers: (fields[3] as List?)?.cast(), + isHD: fields[4] as bool, + isShortcut: fields[5] as bool, + shortcutPath: fields[6] as String?, + width: fields[7] as int?, + height: fields[8] as int?, + extraIds: (fields[9] as List?)?.cast(), + supportsExternalTransfer: fields[10] as bool, + ); + } + + @override + void write(BinaryWriter writer, BaseItem obj) { + writer + ..writeByte(11) + ..writeByte(0) + ..write(obj.size) + ..writeByte(1) + ..write(obj.container) + ..writeByte(2) + ..write(obj.dateLastSaved) + ..writeByte(3) + ..write(obj.remoteTrailers) + ..writeByte(4) + ..write(obj.isHD) + ..writeByte(5) + ..write(obj.isShortcut) + ..writeByte(6) + ..write(obj.shortcutPath) + ..writeByte(7) + ..write(obj.width) + ..writeByte(8) + ..write(obj.height) + ..writeByte(9) + ..write(obj.extraIds) + ..writeByte(10) + ..write(obj.supportsExternalTransfer); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BaseItemAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class QueueItemAdapter extends TypeAdapter { + @override + final int typeId = 35; + + @override + QueueItem read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return QueueItem( + id: fields[0] as String, + playlistItemId: fields[1] as String?, + ); + } + + @override + void write(BinaryWriter writer, QueueItem obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.playlistItemId); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is QueueItemAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class SortByAdapter extends TypeAdapter { + @override + final int typeId = 37; + + @override + SortBy read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return SortBy.album; + case 1: + return SortBy.albumArtist; + case 2: + return SortBy.artist; + case 3: + return SortBy.budget; + case 4: + return SortBy.communityRating; + case 5: + return SortBy.criticRating; + case 6: + return SortBy.dateCreated; + case 7: + return SortBy.datePlayed; + case 8: + return SortBy.playCount; + case 9: + return SortBy.premiereDate; + case 10: + return SortBy.productionYear; + case 11: + return SortBy.sortName; + case 12: + return SortBy.random; + case 13: + return SortBy.revenue; + case 14: + return SortBy.runtime; + default: + return SortBy.album; + } + } + + @override + void write(BinaryWriter writer, SortBy obj) { + switch (obj) { + case SortBy.album: + writer.writeByte(0); + break; + case SortBy.albumArtist: + writer.writeByte(1); + break; + case SortBy.artist: + writer.writeByte(2); + break; + case SortBy.budget: + writer.writeByte(3); + break; + case SortBy.communityRating: + writer.writeByte(4); + break; + case SortBy.criticRating: + writer.writeByte(5); + break; + case SortBy.dateCreated: + writer.writeByte(6); + break; + case SortBy.datePlayed: + writer.writeByte(7); + break; + case SortBy.playCount: + writer.writeByte(8); + break; + case SortBy.premiereDate: + writer.writeByte(9); + break; + case SortBy.productionYear: + writer.writeByte(10); + break; + case SortBy.sortName: + writer.writeByte(11); + break; + case SortBy.random: + writer.writeByte(12); + break; + case SortBy.revenue: + writer.writeByte(13); + break; + case SortBy.runtime: + writer.writeByte(14); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SortByAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class SortOrderAdapter extends TypeAdapter { + @override + final int typeId = 38; + + @override + SortOrder read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return SortOrder.ascending; + case 1: + return SortOrder.descending; + default: + return SortOrder.ascending; + } + } + + @override + void write(BinaryWriter writer, SortOrder obj) { + switch (obj) { + case SortOrder.ascending: + writer.writeByte(0); + break; + case SortOrder.descending: + writer.writeByte(1); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SortOrderAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +UserDto _$UserDtoFromJson(Map json) => UserDto( + name: json['Name'] as String?, + serverId: json['ServerId'] as String?, + serverName: json['ServerName'] as String?, + id: json['Id'] as String, + primaryImageTag: json['PrimaryImageTag'] as String?, + hasPassword: json['HasPassword'] as bool, + hasConfiguredPassword: json['HasConfiguredPassword'] as bool, + hasConfiguredEasyPassword: json['HasConfiguredEasyPassword'] as bool, + enableAutoLogin: json['EnableAutoLogin'] as bool?, + lastLoginDate: json['LastLoginDate'] as String?, + lastActivityDate: json['LastActivityDate'] as String?, + configuration: json['Configuration'] == null + ? null + : UserConfiguration.fromJson( + Map.from(json['Configuration'] as Map)), + policy: json['Policy'] == null + ? null + : UserPolicy.fromJson( + Map.from(json['Policy'] as Map)), + primaryImageAspectRatio: + (json['PrimaryImageAspectRatio'] as num?)?.toDouble(), + ); + +Map _$UserDtoToJson(UserDto instance) => { + 'Name': instance.name, + 'ServerId': instance.serverId, + 'ServerName': instance.serverName, + 'Id': instance.id, + 'PrimaryImageTag': instance.primaryImageTag, + 'HasPassword': instance.hasPassword, + 'HasConfiguredPassword': instance.hasConfiguredPassword, + 'HasConfiguredEasyPassword': instance.hasConfiguredEasyPassword, + 'EnableAutoLogin': instance.enableAutoLogin, + 'LastLoginDate': instance.lastLoginDate, + 'LastActivityDate': instance.lastActivityDate, + 'Configuration': instance.configuration?.toJson(), + 'Policy': instance.policy?.toJson(), + 'PrimaryImageAspectRatio': instance.primaryImageAspectRatio, + }; + +UserConfiguration _$UserConfigurationFromJson(Map json) => UserConfiguration( + audioLanguagePreference: json['AudioLanguagePreference'] as String?, + playDefaultAudioTrack: json['PlayDefaultAudioTrack'] as bool, + subtitleLanguagePreference: json['SubtitleLanguagePreference'] as String?, + displayMissingEpisodes: json['DisplayMissingEpisodes'] as bool, + groupedFolders: (json['GroupedFolders'] as List?) + ?.map((e) => e as String) + .toList(), + subtitleMode: json['SubtitleMode'] as String, + displayCollectionsView: json['DisplayCollectionsView'] as bool, + enableLocalPassword: json['EnableLocalPassword'] as bool, + orderedViews: (json['OrderedViews'] as List?) + ?.map((e) => e as String) + .toList(), + latestItemsExcludes: (json['LatestItemsExcludes'] as List?) + ?.map((e) => e as String) + .toList(), + myMediaExcludes: (json['MyMediaExcludes'] as List?) + ?.map((e) => e as String) + .toList(), + hidePlayedInLatest: json['HidePlayedInLatest'] as bool, + rememberAudioSelections: json['RememberAudioSelections'] as bool, + rememberSubtitleSelections: json['RememberSubtitleSelections'] as bool, + enableNextEpisodeAutoPlay: json['EnableNextEpisodeAutoPlay'] as bool, + ); + +Map _$UserConfigurationToJson(UserConfiguration instance) => + { + 'AudioLanguagePreference': instance.audioLanguagePreference, + 'PlayDefaultAudioTrack': instance.playDefaultAudioTrack, + 'SubtitleLanguagePreference': instance.subtitleLanguagePreference, + 'DisplayMissingEpisodes': instance.displayMissingEpisodes, + 'GroupedFolders': instance.groupedFolders, + 'SubtitleMode': instance.subtitleMode, + 'DisplayCollectionsView': instance.displayCollectionsView, + 'EnableLocalPassword': instance.enableLocalPassword, + 'OrderedViews': instance.orderedViews, + 'LatestItemsExcludes': instance.latestItemsExcludes, + 'MyMediaExcludes': instance.myMediaExcludes, + 'HidePlayedInLatest': instance.hidePlayedInLatest, + 'RememberAudioSelections': instance.rememberAudioSelections, + 'RememberSubtitleSelections': instance.rememberSubtitleSelections, + 'EnableNextEpisodeAutoPlay': instance.enableNextEpisodeAutoPlay, + }; + +UserPolicy _$UserPolicyFromJson(Map json) => UserPolicy( + isAdministrator: json['IsAdministrator'] as bool, + isHidden: json['IsHidden'] as bool, + isDisabled: json['IsDisabled'] as bool, + maxParentalRating: (json['MaxParentalRating'] as num?)?.toInt(), + blockedTags: (json['BlockedTags'] as List?) + ?.map((e) => e as String) + .toList(), + enableUserPreferenceAccess: json['EnableUserPreferenceAccess'] as bool, + accessSchedules: (json['AccessSchedules'] as List?) + ?.map((e) => + AccessSchedule.fromJson(Map.from(e as Map))) + .toList(), + blockUnratedItems: (json['BlockUnratedItems'] as List?) + ?.map((e) => e as String) + .toList(), + enableRemoteControlOfOtherUsers: + json['EnableRemoteControlOfOtherUsers'] as bool, + enableSharedDeviceControl: json['EnableSharedDeviceControl'] as bool, + enableRemoteAccess: json['EnableRemoteAccess'] as bool, + enableLiveTvManagement: json['EnableLiveTvManagement'] as bool, + enableLiveTvAccess: json['EnableLiveTvAccess'] as bool, + enableMediaPlayback: json['EnableMediaPlayback'] as bool, + enableAudioPlaybackTranscoding: + json['EnableAudioPlaybackTranscoding'] as bool, + enableVideoPlaybackTranscoding: + json['EnableVideoPlaybackTranscoding'] as bool, + enablePlaybackRemuxing: json['EnablePlaybackRemuxing'] as bool, + forceRemoteSourceTranscoding: + json['ForceRemoteSourceTranscoding'] as bool?, + enableContentDeletion: json['EnableContentDeletion'] as bool, + enableContentDeletionFromFolders: + (json['EnableContentDeletionFromFolders'] as List?) + ?.map((e) => e as String) + .toList(), + enableContentDownloading: json['EnableContentDownloading'] as bool, + enableSyncTranscoding: json['EnableSyncTranscoding'] as bool, + enableMediaConversion: json['EnableMediaConversion'] as bool, + enabledDevices: (json['EnabledDevices'] as List?) + ?.map((e) => e as String) + .toList(), + enableAllDevices: json['EnableAllDevices'] as bool, + enabledChannels: (json['EnabledChannels'] as List?) + ?.map((e) => e as String) + .toList(), + enableAllChannels: json['EnableAllChannels'] as bool, + enabledFolders: (json['EnabledFolders'] as List?) + ?.map((e) => e as String) + .toList(), + enableAllFolders: json['EnableAllFolders'] as bool, + invalidLoginAttemptCount: + (json['InvalidLoginAttemptCount'] as num).toInt(), + loginAttemptsBeforeLockout: + (json['LoginAttemptsBeforeLockout'] as num?)?.toInt(), + maxActiveSessions: (json['MaxActiveSessions'] as num?)?.toInt(), + enablePublicSharing: json['EnablePublicSharing'] as bool, + blockedMediaFolders: (json['BlockedMediaFolders'] as List?) + ?.map((e) => e as String) + .toList(), + blockedChannels: (json['BlockedChannels'] as List?) + ?.map((e) => e as String) + .toList(), + remoteClientBitrateLimit: + (json['RemoteClientBitrateLimit'] as num).toInt(), + authenticationProviderId: json['AuthenticationProviderId'] as String?, + passwordResetProviderId: json['PasswordResetProviderId'] as String?, + syncPlayAccess: json['SyncPlayAccess'] as String, + ); + +Map _$UserPolicyToJson(UserPolicy instance) => + { + 'IsAdministrator': instance.isAdministrator, + 'IsHidden': instance.isHidden, + 'IsDisabled': instance.isDisabled, + 'MaxParentalRating': instance.maxParentalRating, + 'BlockedTags': instance.blockedTags, + 'EnableUserPreferenceAccess': instance.enableUserPreferenceAccess, + 'AccessSchedules': + instance.accessSchedules?.map((e) => e.toJson()).toList(), + 'BlockUnratedItems': instance.blockUnratedItems, + 'EnableRemoteControlOfOtherUsers': + instance.enableRemoteControlOfOtherUsers, + 'EnableSharedDeviceControl': instance.enableSharedDeviceControl, + 'EnableRemoteAccess': instance.enableRemoteAccess, + 'EnableLiveTvManagement': instance.enableLiveTvManagement, + 'EnableLiveTvAccess': instance.enableLiveTvAccess, + 'EnableMediaPlayback': instance.enableMediaPlayback, + 'EnableAudioPlaybackTranscoding': instance.enableAudioPlaybackTranscoding, + 'EnableVideoPlaybackTranscoding': instance.enableVideoPlaybackTranscoding, + 'EnablePlaybackRemuxing': instance.enablePlaybackRemuxing, + 'EnableContentDeletion': instance.enableContentDeletion, + 'EnableContentDeletionFromFolders': + instance.enableContentDeletionFromFolders, + 'EnableContentDownloading': instance.enableContentDownloading, + 'EnableSyncTranscoding': instance.enableSyncTranscoding, + 'EnableMediaConversion': instance.enableMediaConversion, + 'EnabledDevices': instance.enabledDevices, + 'EnableAllDevices': instance.enableAllDevices, + 'EnabledChannels': instance.enabledChannels, + 'EnableAllChannels': instance.enableAllChannels, + 'EnabledFolders': instance.enabledFolders, + 'EnableAllFolders': instance.enableAllFolders, + 'InvalidLoginAttemptCount': instance.invalidLoginAttemptCount, + 'EnablePublicSharing': instance.enablePublicSharing, + 'BlockedMediaFolders': instance.blockedMediaFolders, + 'BlockedChannels': instance.blockedChannels, + 'RemoteClientBitrateLimit': instance.remoteClientBitrateLimit, + 'AuthenticationProviderId': instance.authenticationProviderId, + 'ForceRemoteSourceTranscoding': instance.forceRemoteSourceTranscoding, + 'LoginAttemptsBeforeLockout': instance.loginAttemptsBeforeLockout, + 'MaxActiveSessions': instance.maxActiveSessions, + 'PasswordResetProviderId': instance.passwordResetProviderId, + 'SyncPlayAccess': instance.syncPlayAccess, + }; + +AccessSchedule _$AccessScheduleFromJson(Map json) => AccessSchedule( + id: (json['Id'] as num).toInt(), + userId: json['UserId'] as String, + dayOfWeek: json['DayOfWeek'] as String, + startHour: (json['StartHour'] as num).toDouble(), + endHour: (json['EndHour'] as num).toDouble(), + ); + +Map _$AccessScheduleToJson(AccessSchedule instance) => + { + 'DayOfWeek': instance.dayOfWeek, + 'StartHour': instance.startHour, + 'EndHour': instance.endHour, + 'Id': instance.id, + 'UserId': instance.userId, + }; + +AuthenticationResult _$AuthenticationResultFromJson(Map json) => + AuthenticationResult( + user: json['User'] == null + ? null + : UserDto.fromJson(Map.from(json['User'] as Map)), + sessionInfo: json['SessionInfo'] == null + ? null + : SessionInfo.fromJson( + Map.from(json['SessionInfo'] as Map)), + accessToken: json['AccessToken'] as String?, + serverId: json['ServerId'] as String?, + ); + +Map _$AuthenticationResultToJson( + AuthenticationResult instance) => + { + 'User': instance.user?.toJson(), + 'SessionInfo': instance.sessionInfo?.toJson(), + 'AccessToken': instance.accessToken, + 'ServerId': instance.serverId, + }; + +SessionInfo _$SessionInfoFromJson(Map json) => SessionInfo( + playState: json['PlayState'] == null + ? null + : PlayerStateInfo.fromJson( + Map.from(json['PlayState'] as Map)), + additionalUsers: (json['AdditionalUsers'] as List?) + ?.map((e) => + SessionUserInfo.fromJson(Map.from(e as Map))) + .toList(), + capabilities: json['Capabilities'] == null + ? null + : ClientCapabilities.fromJson( + Map.from(json['Capabilities'] as Map)), + remoteEndPoint: json['RemoteEndPoint'] as String?, + playableMediaTypes: (json['PlayableMediaTypes'] as List?) + ?.map((e) => e as String) + .toList(), + playlistItemId: json['PlaylistItemId'] as String?, + id: json['Id'] as String?, + serverId: json['ServerId'] as String?, + userId: json['UserId'] as String, + userName: json['UserName'] as String?, + userPrimaryImageTag: json['UserPrimaryImageTag'] as String?, + client: json['Client'] as String?, + lastActivityDate: json['LastActivityDate'] as String, + deviceName: json['DeviceName'] as String?, + deviceType: json['DeviceType'] as String?, + nowPlayingItem: json['NowPlayingItem'] == null + ? null + : BaseItemDto.fromJson( + Map.from(json['NowPlayingItem'] as Map)), + deviceId: json['DeviceId'] as String?, + supportedCommands: (json['SupportedCommands'] as List?) + ?.map((e) => e as String) + .toList(), + transcodingInfo: json['TranscodingInfo'] == null + ? null + : TranscodingInfo.fromJson( + Map.from(json['TranscodingInfo'] as Map)), + supportsRemoteControl: json['SupportsRemoteControl'] as bool, + lastPlaybackCheckIn: json['LastPlaybackCheckIn'] as String?, + fullNowPlayingItem: json['FullNowPlayingItem'] == null + ? null + : BaseItem.fromJson( + Map.from(json['FullNowPlayingItem'] as Map)), + nowViewingItem: json['NowViewingItem'] == null + ? null + : BaseItemDto.fromJson( + Map.from(json['NowViewingItem'] as Map)), + applicationVersion: json['ApplicationVersion'] as String?, + isActive: json['IsActive'] as bool, + supportsMediaControl: json['SupportsMediaControl'] as bool, + nowPlayingQueue: (json['NowPlayingQueue'] as List?) + ?.map((e) => QueueItem.fromJson(Map.from(e as Map))) + .toList(), + hasCustomDeviceName: json['HasCustomDeviceName'] as bool, + ); + +Map _$SessionInfoToJson(SessionInfo instance) => + { + 'PlayState': instance.playState?.toJson(), + 'AdditionalUsers': + instance.additionalUsers?.map((e) => e.toJson()).toList(), + 'Capabilities': instance.capabilities?.toJson(), + 'RemoteEndPoint': instance.remoteEndPoint, + 'PlayableMediaTypes': instance.playableMediaTypes, + 'PlaylistItemId': instance.playlistItemId, + 'Id': instance.id, + 'ServerId': instance.serverId, + 'UserId': instance.userId, + 'UserName': instance.userName, + 'UserPrimaryImageTag': instance.userPrimaryImageTag, + 'Client': instance.client, + 'LastActivityDate': instance.lastActivityDate, + 'DeviceName': instance.deviceName, + 'DeviceType': instance.deviceType, + 'NowPlayingItem': instance.nowPlayingItem?.toJson(), + 'DeviceId': instance.deviceId, + 'SupportedCommands': instance.supportedCommands, + 'TranscodingInfo': instance.transcodingInfo?.toJson(), + 'SupportsRemoteControl': instance.supportsRemoteControl, + 'LastPlaybackCheckIn': instance.lastPlaybackCheckIn, + 'FullNowPlayingItem': instance.fullNowPlayingItem?.toJson(), + 'NowViewingItem': instance.nowViewingItem?.toJson(), + 'ApplicationVersion': instance.applicationVersion, + 'IsActive': instance.isActive, + 'SupportsMediaControl': instance.supportsMediaControl, + 'NowPlayingQueue': + instance.nowPlayingQueue?.map((e) => e.toJson()).toList(), + 'HasCustomDeviceName': instance.hasCustomDeviceName, + }; + +TranscodingInfo _$TranscodingInfoFromJson(Map json) => TranscodingInfo( + audioCodec: json['AudioCodec'] as String?, + videoCodec: json['VideoCodec'] as String?, + container: json['Container'] as String?, + isVideoDirect: json['IsVideoDirect'] as bool, + isAudioDirect: json['IsAudioDirect'] as bool, + bitrate: (json['Bitrate'] as num?)?.toInt(), + framerate: (json['Framerate'] as num?)?.toDouble(), + completionPercentage: (json['CompletionPercentage'] as num?)?.toDouble(), + width: (json['Width'] as num?)?.toInt(), + height: (json['Height'] as num?)?.toInt(), + audioChannels: (json['AudioChannels'] as num?)?.toInt(), + transcodeReasons: (json['TranscodeReasons'] as List?) + ?.map((e) => e as String) + .toList(), + ); + +Map _$TranscodingInfoToJson(TranscodingInfo instance) => + { + 'AudioCodec': instance.audioCodec, + 'VideoCodec': instance.videoCodec, + 'Container': instance.container, + 'IsVideoDirect': instance.isVideoDirect, + 'IsAudioDirect': instance.isAudioDirect, + 'Bitrate': instance.bitrate, + 'Framerate': instance.framerate, + 'CompletionPercentage': instance.completionPercentage, + 'Width': instance.width, + 'Height': instance.height, + 'AudioChannels': instance.audioChannels, + 'TranscodeReasons': instance.transcodeReasons, + }; + +PlayerStateInfo _$PlayerStateInfoFromJson(Map json) => PlayerStateInfo( + positionTicks: (json['PositionTicks'] as num?)?.toInt(), + canSeek: json['CanSeek'] as bool, + isPaused: json['IsPaused'] as bool, + isMuted: json['IsMuted'] as bool, + volumeLevel: (json['VolumeLevel'] as num?)?.toInt(), + audioStreamIndex: (json['AudioStreamIndex'] as num?)?.toInt(), + subtitleStreamIndex: (json['SubtitleStreamIndex'] as num?)?.toInt(), + mediaSourceId: json['MediaSourceId'] as String?, + playMethod: json['PlayMethod'] as String?, + repeatMode: json['RepeatMode'] as String?, + ); + +Map _$PlayerStateInfoToJson(PlayerStateInfo instance) => + { + 'PositionTicks': instance.positionTicks, + 'CanSeek': instance.canSeek, + 'IsPaused': instance.isPaused, + 'IsMuted': instance.isMuted, + 'VolumeLevel': instance.volumeLevel, + 'AudioStreamIndex': instance.audioStreamIndex, + 'SubtitleStreamIndex': instance.subtitleStreamIndex, + 'MediaSourceId': instance.mediaSourceId, + 'PlayMethod': instance.playMethod, + 'RepeatMode': instance.repeatMode, + }; + +SessionUserInfo _$SessionUserInfoFromJson(Map json) => SessionUserInfo( + userId: json['UserId'] as String, + userName: json['UserName'] as String?, + ); + +Map _$SessionUserInfoToJson(SessionUserInfo instance) => + { + 'UserId': instance.userId, + 'UserName': instance.userName, + }; + +ClientCapabilities _$ClientCapabilitiesFromJson(Map json) => ClientCapabilities( + playableMediaTypes: (json['PlayableMediaTypes'] as List?) + ?.map((e) => e as String) + .toList(), + supportedCommands: (json['SupportedCommands'] as List?) + ?.map((e) => e as String) + .toList(), + supportsMediaControl: json['SupportsMediaControl'] as bool?, + supportsPersistentIdentifier: + json['SupportsPersistentIdentifier'] as bool?, + supportsSync: json['SupportsSync'] as bool?, + deviceProfile: json['DeviceProfile'] == null + ? null + : DeviceProfile.fromJson( + Map.from(json['DeviceProfile'] as Map)), + iconUrl: json['IconUrl'] as String?, + supportsContentUploading: json['SupportsContentUploading'] as bool?, + messageCallbackUrl: json['MessageCallbackUrl'] as String?, + appStoreUrl: json['AppStoreUrl'] as String?, + ); + +Map _$ClientCapabilitiesToJson(ClientCapabilities instance) => + { + 'PlayableMediaTypes': instance.playableMediaTypes, + 'SupportedCommands': instance.supportedCommands, + 'SupportsMediaControl': instance.supportsMediaControl, + 'SupportsPersistentIdentifier': instance.supportsPersistentIdentifier, + 'SupportsSync': instance.supportsSync, + 'DeviceProfile': instance.deviceProfile?.toJson(), + 'IconUrl': instance.iconUrl, + 'SupportsContentUploading': instance.supportsContentUploading, + 'MessageCallbackUrl': instance.messageCallbackUrl, + 'AppStoreUrl': instance.appStoreUrl, + }; + +DeviceProfile _$DeviceProfileFromJson(Map json) => DeviceProfile( + name: json['Name'] as String?, + id: json['Id'] as String?, + identification: json['Identification'] == null + ? null + : DeviceIdentification.fromJson( + Map.from(json['Identification'] as Map)), + friendlyName: json['FriendlyName'] as String?, + manufacturer: json['Manufacturer'] as String?, + manufacturerUrl: json['ManufacturerUrl'] as String?, + modelName: json['ModelName'] as String?, + modelDescription: json['ModelDescription'] as String?, + modelNumber: json['ModelNumber'] as String?, + modelUrl: json['ModelUrl'] as String?, + serialNumber: json['SerialNumber'] as String?, + enableAlbumArtInDidl: json['EnableAlbumArtInDidl'] as bool, + enableSingleAlbumArtLimit: json['EnableSingleAlbumArtLimit'] as bool, + enableSingleSubtitleLimit: json['EnableSingleSubtitleLimit'] as bool, + supportedMediaTypes: json['SupportedMediaTypes'] as String?, + userId: json['UserId'] as String?, + albumArtPn: json['AlbumArtPn'] as String?, + maxAlbumArtWidth: (json['MaxAlbumArtWidth'] as num).toInt(), + maxAlbumArtHeight: (json['MaxAlbumArtHeight'] as num).toInt(), + maxIconWidth: (json['MaxIconWidth'] as num?)?.toInt(), + maxIconHeight: (json['MaxIconHeight'] as num?)?.toInt(), + maxStreamingBitrate: (json['MaxStreamingBitrate'] as num?)?.toInt(), + maxStaticBitrate: (json['MaxStaticBitrate'] as num?)?.toInt(), + musicStreamingTranscodingBitrate: + (json['MusicStreamingTranscodingBitrate'] as num?)?.toInt(), + maxStaticMusicBitrate: (json['MaxStaticMusicBitrate'] as num?)?.toInt(), + sonyAggregationFlags: json['SonyAggregationFlags'] as String?, + protocolInfo: json['ProtocolInfo'] as String?, + timelineOffsetSeconds: (json['TimelineOffsetSeconds'] as num).toInt(), + requiresPlainVideoItems: json['RequiresPlainVideoItems'] as bool, + requiresPlainFolders: json['RequiresPlainFolders'] as bool, + enableMSMediaReceiverRegistrar: + json['EnableMSMediaReceiverRegistrar'] as bool, + ignoreTranscodeByteRangeRequests: + json['IgnoreTranscodeByteRangeRequests'] as bool, + xmlRootAttributes: (json['XmlRootAttributes'] as List?) + ?.map( + (e) => XmlAttribute.fromJson(Map.from(e as Map))) + .toList(), + directPlayProfiles: (json['DirectPlayProfiles'] as List?) + ?.map((e) => + DirectPlayProfile.fromJson(Map.from(e as Map))) + .toList(), + transcodingProfiles: (json['TranscodingProfiles'] as List?) + ?.map((e) => + TranscodingProfile.fromJson(Map.from(e as Map))) + .toList(), + containerProfiles: (json['ContainerProfiles'] as List?) + ?.map((e) => + ContainerProfile.fromJson(Map.from(e as Map))) + .toList(), + codecProfiles: (json['CodecProfiles'] as List?) + ?.map( + (e) => CodecProfile.fromJson(Map.from(e as Map))) + .toList(), + responseProfiles: (json['ResponseProfiles'] as List?) + ?.map((e) => + ResponseProfile.fromJson(Map.from(e as Map))) + .toList(), + subtitleProfiles: (json['SubtitleProfiles'] as List?) + ?.map((e) => + SubtitleProfile.fromJson(Map.from(e as Map))) + .toList(), + ); + +Map _$DeviceProfileToJson(DeviceProfile instance) => + { + 'Name': instance.name, + 'Id': instance.id, + 'Identification': instance.identification?.toJson(), + 'FriendlyName': instance.friendlyName, + 'Manufacturer': instance.manufacturer, + 'ManufacturerUrl': instance.manufacturerUrl, + 'ModelName': instance.modelName, + 'ModelDescription': instance.modelDescription, + 'ModelNumber': instance.modelNumber, + 'ModelUrl': instance.modelUrl, + 'SerialNumber': instance.serialNumber, + 'EnableAlbumArtInDidl': instance.enableAlbumArtInDidl, + 'EnableSingleAlbumArtLimit': instance.enableSingleAlbumArtLimit, + 'EnableSingleSubtitleLimit': instance.enableSingleSubtitleLimit, + 'SupportedMediaTypes': instance.supportedMediaTypes, + 'UserId': instance.userId, + 'AlbumArtPn': instance.albumArtPn, + 'MaxAlbumArtWidth': instance.maxAlbumArtWidth, + 'MaxAlbumArtHeight': instance.maxAlbumArtHeight, + 'MaxIconWidth': instance.maxIconWidth, + 'MaxIconHeight': instance.maxIconHeight, + 'MaxStreamingBitrate': instance.maxStreamingBitrate, + 'MaxStaticBitrate': instance.maxStaticBitrate, + 'MusicStreamingTranscodingBitrate': + instance.musicStreamingTranscodingBitrate, + 'MaxStaticMusicBitrate': instance.maxStaticMusicBitrate, + 'SonyAggregationFlags': instance.sonyAggregationFlags, + 'ProtocolInfo': instance.protocolInfo, + 'TimelineOffsetSeconds': instance.timelineOffsetSeconds, + 'RequiresPlainVideoItems': instance.requiresPlainVideoItems, + 'RequiresPlainFolders': instance.requiresPlainFolders, + 'EnableMSMediaReceiverRegistrar': instance.enableMSMediaReceiverRegistrar, + 'IgnoreTranscodeByteRangeRequests': + instance.ignoreTranscodeByteRangeRequests, + 'XmlRootAttributes': + instance.xmlRootAttributes?.map((e) => e.toJson()).toList(), + 'DirectPlayProfiles': + instance.directPlayProfiles?.map((e) => e.toJson()).toList(), + 'TranscodingProfiles': + instance.transcodingProfiles?.map((e) => e.toJson()).toList(), + 'ContainerProfiles': + instance.containerProfiles?.map((e) => e.toJson()).toList(), + 'CodecProfiles': instance.codecProfiles?.map((e) => e.toJson()).toList(), + 'ResponseProfiles': + instance.responseProfiles?.map((e) => e.toJson()).toList(), + 'SubtitleProfiles': + instance.subtitleProfiles?.map((e) => e.toJson()).toList(), + }; + +DeviceIdentification _$DeviceIdentificationFromJson(Map json) => + DeviceIdentification( + friendlyName: json['FriendlyName'] as String?, + modelNumber: json['ModelNumber'] as String?, + serialNumber: json['SerialNumber'] as String?, + modelName: json['ModelName'] as String?, + modelDescription: json['ModelDescription'] as String?, + modelUrl: json['ModelUrl'] as String?, + manufacturer: json['Manufacturer'] as String?, + manufacturerUrl: json['ManufacturerUrl'] as String?, + headers: (json['Headers'] as List?) + ?.map((e) => + HttpHeaderInfo.fromJson(Map.from(e as Map))) + .toList(), + ); + +Map _$DeviceIdentificationToJson( + DeviceIdentification instance) => + { + 'FriendlyName': instance.friendlyName, + 'ModelNumber': instance.modelNumber, + 'SerialNumber': instance.serialNumber, + 'ModelName': instance.modelName, + 'ModelDescription': instance.modelDescription, + 'ModelUrl': instance.modelUrl, + 'Manufacturer': instance.manufacturer, + 'ManufacturerUrl': instance.manufacturerUrl, + 'Headers': instance.headers?.map((e) => e.toJson()).toList(), + }; + +HttpHeaderInfo _$HttpHeaderInfoFromJson(Map json) => HttpHeaderInfo( + name: json['Name'] as String?, + value: json['Value'] as String?, + match: json['Match'] as String, + ); + +Map _$HttpHeaderInfoToJson(HttpHeaderInfo instance) => + { + 'Name': instance.name, + 'Value': instance.value, + 'Match': instance.match, + }; + +XmlAttribute _$XmlAttributeFromJson(Map json) => XmlAttribute( + name: json['Name'] as String?, + value: json['Value'] as String?, + ); + +Map _$XmlAttributeToJson(XmlAttribute instance) => + { + 'Name': instance.name, + 'Value': instance.value, + }; + +DirectPlayProfile _$DirectPlayProfileFromJson(Map json) => DirectPlayProfile( + container: json['Container'] as String?, + audioCodec: json['AudioCodec'] as String?, + videoCodec: json['VideoCodec'] as String?, + type: json['Type'] as String, + ); + +Map _$DirectPlayProfileToJson(DirectPlayProfile instance) => + { + 'Container': instance.container, + 'AudioCodec': instance.audioCodec, + 'VideoCodec': instance.videoCodec, + 'Type': instance.type, + }; + +TranscodingProfile _$TranscodingProfileFromJson(Map json) => TranscodingProfile( + container: json['Container'] as String?, + type: json['Type'] as String, + videoCodec: json['VideoCodec'] as String?, + audioCodec: json['AudioCodec'] as String?, + protocol: json['Protocol'] as String?, + estimateContentLength: json['EstimateContentLength'] as bool, + enableMpegtsM2TsMode: json['EnableMpegtsM2TsMode'] as bool, + transcodeSeekInfo: json['TranscodeSeekInfo'] as String, + copyTimestamps: json['CopyTimestamps'] as bool, + context: json['Context'] as String, + maxAudioChannels: json['MaxAudioChannels'] as String?, + minSegments: (json['MinSegments'] as num).toInt(), + segmentLength: (json['SegmentLength'] as num).toInt(), + breakOnNonKeyFrames: json['BreakOnNonKeyFrames'] as bool, + enableSubtitlesInManifest: json['EnableSubtitlesInManifest'] as bool, + ); + +Map _$TranscodingProfileToJson(TranscodingProfile instance) => + { + 'Container': instance.container, + 'Type': instance.type, + 'VideoCodec': instance.videoCodec, + 'AudioCodec': instance.audioCodec, + 'Protocol': instance.protocol, + 'EstimateContentLength': instance.estimateContentLength, + 'EnableMpegtsM2TsMode': instance.enableMpegtsM2TsMode, + 'TranscodeSeekInfo': instance.transcodeSeekInfo, + 'CopyTimestamps': instance.copyTimestamps, + 'Context': instance.context, + 'MaxAudioChannels': instance.maxAudioChannels, + 'MinSegments': instance.minSegments, + 'SegmentLength': instance.segmentLength, + 'BreakOnNonKeyFrames': instance.breakOnNonKeyFrames, + 'EnableSubtitlesInManifest': instance.enableSubtitlesInManifest, + }; + +ContainerProfile _$ContainerProfileFromJson(Map json) => ContainerProfile( + type: json['Type'] as String, + conditions: (json['Conditions'] as List?) + ?.map((e) => + ProfileCondition.fromJson(Map.from(e as Map))) + .toList(), + container: json['Container'] as String?, + ); + +Map _$ContainerProfileToJson(ContainerProfile instance) => + { + 'Type': instance.type, + 'Conditions': instance.conditions?.map((e) => e.toJson()).toList(), + 'Container': instance.container, + }; + +ProfileCondition _$ProfileConditionFromJson(Map json) => ProfileCondition( + condition: json['Condition'] as String, + property: json['Property'] as String, + value: json['Value'] as String?, + isRequired: json['IsRequired'] as bool, + ); + +Map _$ProfileConditionToJson(ProfileCondition instance) => + { + 'Condition': instance.condition, + 'Property': instance.property, + 'Value': instance.value, + 'IsRequired': instance.isRequired, + }; + +CodecProfile _$CodecProfileFromJson(Map json) => CodecProfile( + type: json['Type'] as String, + conditions: (json['Conditions'] as List?) + ?.map((e) => + ProfileCondition.fromJson(Map.from(e as Map))) + .toList(), + applyConditions: (json['ApplyConditions'] as List?) + ?.map((e) => + ProfileCondition.fromJson(Map.from(e as Map))) + .toList(), + codec: json['Codec'] as String?, + container: json['Container'] as String?, + ); + +Map _$CodecProfileToJson(CodecProfile instance) => + { + 'Type': instance.type, + 'Conditions': instance.conditions?.map((e) => e.toJson()).toList(), + 'ApplyConditions': + instance.applyConditions?.map((e) => e.toJson()).toList(), + 'Codec': instance.codec, + 'Container': instance.container, + }; + +ResponseProfile _$ResponseProfileFromJson(Map json) => ResponseProfile( + container: json['Container'] as String?, + audioCodec: json['AudioCodec'] as String?, + videoCodec: json['VideoCodec'] as String?, + type: json['Type'] as String, + orgPn: json['OrgPn'] as String?, + mimeType: json['MimeType'] as String?, + conditions: (json['Conditions'] as List?) + ?.map((e) => + ProfileCondition.fromJson(Map.from(e as Map))) + .toList(), + ); + +Map _$ResponseProfileToJson(ResponseProfile instance) => + { + 'Container': instance.container, + 'AudioCodec': instance.audioCodec, + 'VideoCodec': instance.videoCodec, + 'Type': instance.type, + 'OrgPn': instance.orgPn, + 'MimeType': instance.mimeType, + 'Conditions': instance.conditions?.map((e) => e.toJson()).toList(), + }; + +SubtitleProfile _$SubtitleProfileFromJson(Map json) => SubtitleProfile( + format: json['Format'] as String?, + method: json['Method'] as String, + didlMode: json['DidlMode'] as String?, + language: json['Language'] as String?, + container: json['Container'] as String?, + ); + +Map _$SubtitleProfileToJson(SubtitleProfile instance) => + { + 'Format': instance.format, + 'Method': instance.method, + 'DidlMode': instance.didlMode, + 'Language': instance.language, + 'Container': instance.container, + }; + +BaseItemDto _$BaseItemDtoFromJson(Map json) => BaseItemDto( + name: json['Name'] as String?, + originalTitle: json['OriginalTitle'] as String?, + serverId: json['ServerId'] as String?, + id: json['Id'] as String, + etag: json['Etag'] as String?, + playlistItemId: json['PlaylistItemId'] as String?, + dateCreated: json['DateCreated'] as String?, + extraType: json['ExtraType'] as String?, + airsBeforeSeasonNumber: (json['AirsBeforeSeasonNumber'] as num?)?.toInt(), + airsAfterSeasonNumber: (json['AirsAfterSeasonNumber'] as num?)?.toInt(), + airsBeforeEpisodeNumber: + (json['AirsBeforeEpisodeNumber'] as num?)?.toInt(), + canDelete: json['CanDelete'] as bool?, + canDownload: json['CanDownload'] as bool?, + hasSubtitles: json['HasSubtitles'] as bool?, + preferredMetadataLanguage: json['PreferredMetadataLanguage'] as String?, + preferredMetadataCountryCode: + json['PreferredMetadataCountryCode'] as String?, + supportsSync: json['SupportsSync'] as bool?, + container: json['Container'] as String?, + sortName: json['SortName'] as String?, + forcedSortName: json['ForcedSortName'] as String?, + video3DFormat: json['Video3DFormat'] as String?, + premiereDate: json['PremiereDate'] as String?, + externalUrls: (json['ExternalUrls'] as List?) + ?.map( + (e) => ExternalUrl.fromJson(Map.from(e as Map))) + .toList(), + mediaSources: (json['MediaSources'] as List?) + ?.map((e) => + MediaSourceInfo.fromJson(Map.from(e as Map))) + .toList(), + criticRating: (json['CriticRating'] as num?)?.toDouble(), + productionLocations: (json['ProductionLocations'] as List?) + ?.map((e) => e as String) + .toList(), + path: json['Path'] as String?, + officialRating: json['OfficialRating'] as String?, + customRating: json['CustomRating'] as String?, + channelId: json['ChannelId'] as String?, + channelName: json['ChannelName'] as String?, + overview: json['Overview'] as String?, + taglines: (json['Taglines'] as List?) + ?.map((e) => e as String) + .toList(), + genres: + (json['Genres'] as List?)?.map((e) => e as String).toList(), + communityRating: (json['CommunityRating'] as num?)?.toDouble(), + runTimeTicks: (json['RunTimeTicks'] as num?)?.toInt(), + playAccess: json['PlayAccess'] as String?, + aspectRatio: json['AspectRatio'] as String?, + productionYear: (json['ProductionYear'] as num?)?.toInt(), + number: json['Number'] as String?, + channelNumber: json['ChannelNumber'] as String?, + indexNumber: (json['IndexNumber'] as num?)?.toInt(), + indexNumberEnd: (json['IndexNumberEnd'] as num?)?.toInt(), + parentIndexNumber: (json['ParentIndexNumber'] as num?)?.toInt(), + remoteTrailers: (json['RemoteTrailers'] as List?) + ?.map((e) => MediaUrl.fromJson(Map.from(e as Map))) + .toList(), + providerIds: (json['ProviderIds'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + ), + isFolder: json['IsFolder'] as bool?, + parentId: json['ParentId'] as String?, + type: json['Type'] as String?, + people: (json['People'] as List?) + ?.map((e) => + BaseItemPerson.fromJson(Map.from(e as Map))) + .toList(), + studios: (json['Studios'] as List?) + ?.map((e) => + NameLongIdPair.fromJson(Map.from(e as Map))) + .toList(), + genreItems: (json['GenreItems'] as List?) + ?.map((e) => + NameLongIdPair.fromJson(Map.from(e as Map))) + .toList(), + parentLogoItemId: json['ParentLogoItemId'] as String?, + parentBackdropItemId: json['ParentBackdropItemId'] as String?, + parentBackdropImageTags: + (json['ParentBackdropImageTags'] as List?) + ?.map((e) => e as String) + .toList(), + localTrailerCount: (json['LocalTrailerCount'] as num?)?.toInt(), + userData: json['UserData'] == null + ? null + : UserItemDataDto.fromJson( + Map.from(json['UserData'] as Map)), + recursiveItemCount: (json['RecursiveItemCount'] as num?)?.toInt(), + childCount: (json['ChildCount'] as num?)?.toInt(), + seriesName: json['SeriesName'] as String?, + seriesId: json['SeriesId'] as String?, + seasonId: json['SeasonId'] as String?, + specialFeatureCount: (json['SpecialFeatureCount'] as num?)?.toInt(), + displayPreferencesId: json['DisplayPreferencesId'] as String?, + status: json['Status'] as String?, + airTime: json['AirTime'] as String?, + airDays: + (json['AirDays'] as List?)?.map((e) => e as String).toList(), + tags: (json['Tags'] as List?)?.map((e) => e as String).toList(), + primaryImageAspectRatio: + (json['PrimaryImageAspectRatio'] as num?)?.toDouble(), + artists: + (json['Artists'] as List?)?.map((e) => e as String).toList(), + artistItems: (json['ArtistItems'] as List?) + ?.map((e) => NameIdPair.fromJson(Map.from(e as Map))) + .toList(), + album: json['Album'] as String?, + collectionType: json['CollectionType'] as String?, + displayOrder: json['DisplayOrder'] as String?, + albumId: json['AlbumId'] as String?, + albumPrimaryImageTag: json['AlbumPrimaryImageTag'] as String?, + seriesPrimaryImageTag: json['SeriesPrimaryImageTag'] as String?, + albumArtist: json['AlbumArtist'] as String?, + albumArtists: (json['AlbumArtists'] as List?) + ?.map((e) => NameIdPair.fromJson(Map.from(e as Map))) + .toList(), + seasonName: json['SeasonName'] as String?, + mediaStreams: (json['MediaStreams'] as List?) + ?.map( + (e) => MediaStream.fromJson(Map.from(e as Map))) + .toList(), + partCount: (json['PartCount'] as num?)?.toInt(), + imageTags: (json['ImageTags'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ), + backdropImageTags: (json['BackdropImageTags'] as List?) + ?.map((e) => e as String) + .toList(), + parentLogoImageTag: json['ParentLogoImageTag'] as String?, + parentArtItemId: json['ParentArtItemId'] as String?, + parentArtImageTag: json['ParentArtImageTag'] as String?, + seriesThumbImageTag: json['SeriesThumbImageTag'] as String?, + seriesStudio: json['SeriesStudio'] as String?, + parentThumbItemId: json['ParentThumbItemId'] as String?, + parentThumbImageTag: json['ParentThumbImageTag'] as String?, + parentPrimaryImageItemId: json['ParentPrimaryImageItemId'] as String?, + parentPrimaryImageTag: json['ParentPrimaryImageTag'] as String?, + chapters: (json['Chapters'] as List?) + ?.map( + (e) => ChapterInfo.fromJson(Map.from(e as Map))) + .toList(), + locationType: json['LocationType'] as String?, + mediaType: json['MediaType'] as String?, + endDate: json['EndDate'] as String?, + lockedFields: (json['LockedFields'] as List?) + ?.map((e) => e as String) + .toList(), + lockData: json['LockData'] as bool?, + width: (json['Width'] as num?)?.toInt(), + height: (json['Height'] as num?)?.toInt(), + cameraMake: json['CameraMake'] as String?, + cameraModel: json['CameraModel'] as String?, + software: json['Software'] as String?, + exposureTime: (json['ExposureTime'] as num?)?.toDouble(), + focalLength: (json['FocalLength'] as num?)?.toDouble(), + imageOrientation: json['ImageOrientation'] as String?, + aperture: (json['Aperture'] as num?)?.toDouble(), + shutterSpeed: (json['ShutterSpeed'] as num?)?.toDouble(), + latitude: (json['Latitude'] as num?)?.toDouble(), + longitude: (json['Longitude'] as num?)?.toDouble(), + altitude: (json['Altitude'] as num?)?.toDouble(), + isoSpeedRating: (json['IsoSpeedRating'] as num?)?.toInt(), + seriesTimerId: json['SeriesTimerId'] as String?, + channelPrimaryImageTag: json['ChannelPrimaryImageTag'] as String?, + startDate: json['StartDate'] as String?, + completionPercentage: (json['CompletionPercentage'] as num?)?.toDouble(), + isRepeat: json['IsRepeat'] as bool?, + episodeTitle: json['EpisodeTitle'] as String?, + isMovie: json['IsMovie'] as bool?, + isSports: json['IsSports'] as bool?, + isSeries: json['IsSeries'] as bool?, + isLive: json['IsLive'] as bool?, + isNews: json['IsNews'] as bool?, + isKids: json['IsKids'] as bool?, + isPremiere: json['IsPremiere'] as bool?, + timerId: json['TimerId'] as String?, + currentProgram: json['CurrentProgram'], + movieCount: (json['MovieCount'] as num?)?.toInt(), + seriesCount: (json['SeriesCount'] as num?)?.toInt(), + albumCount: (json['AlbumCount'] as num?)?.toInt(), + songCount: (json['SongCount'] as num?)?.toInt(), + musicVideoCount: (json['MusicVideoCount'] as num?)?.toInt(), + sourceType: json['SourceType'] as String?, + dateLastMediaAdded: json['DateLastMediaAdded'] as String?, + enableMediaSourceDisplay: json['EnableMediaSourceDisplay'] as bool?, + cumulativeRunTimeTicks: (json['CumulativeRunTimeTicks'] as num?)?.toInt(), + isPlaceHolder: json['IsPlaceHolder'] as bool?, + isHD: json['IsHD'] as bool?, + videoType: json['VideoType'] as String?, + mediaSourceCount: (json['MediaSourceCount'] as num?)?.toInt(), + screenshotImageTags: (json['ScreenshotImageTags'] as List?) + ?.map((e) => e as String) + .toList(), + imageBlurHashes: json['ImageBlurHashes'] == null + ? null + : ImageBlurHashes.fromJson( + Map.from(json['ImageBlurHashes'] as Map)), + isoType: json['IsoType'] as String?, + trailerCount: (json['TrailerCount'] as num?)?.toInt(), + programCount: (json['ProgramCount'] as num?)?.toInt(), + episodeCount: (json['EpisodeCount'] as num?)?.toInt(), + artistCount: (json['ArtistCount'] as num?)?.toInt(), + programId: json['ProgramId'] as String?, + channelType: json['ChannelType'] as String?, + audio: json['Audio'] as String?, + ); + +Map _$BaseItemDtoToJson(BaseItemDto instance) => + { + 'Name': instance.name, + 'OriginalTitle': instance.originalTitle, + 'ServerId': instance.serverId, + 'Id': instance.id, + 'Etag': instance.etag, + 'PlaylistItemId': instance.playlistItemId, + 'DateCreated': instance.dateCreated, + 'ExtraType': instance.extraType, + 'AirsBeforeSeasonNumber': instance.airsBeforeSeasonNumber, + 'AirsAfterSeasonNumber': instance.airsAfterSeasonNumber, + 'AirsBeforeEpisodeNumber': instance.airsBeforeEpisodeNumber, + 'CanDelete': instance.canDelete, + 'CanDownload': instance.canDownload, + 'HasSubtitles': instance.hasSubtitles, + 'PreferredMetadataLanguage': instance.preferredMetadataLanguage, + 'PreferredMetadataCountryCode': instance.preferredMetadataCountryCode, + 'SupportsSync': instance.supportsSync, + 'Container': instance.container, + 'SortName': instance.sortName, + 'ForcedSortName': instance.forcedSortName, + 'Video3DFormat': instance.video3DFormat, + 'PremiereDate': instance.premiereDate, + 'ExternalUrls': instance.externalUrls?.map((e) => e.toJson()).toList(), + 'MediaSources': instance.mediaSources?.map((e) => e.toJson()).toList(), + 'CriticRating': instance.criticRating, + 'ProductionLocations': instance.productionLocations, + 'Path': instance.path, + 'OfficialRating': instance.officialRating, + 'CustomRating': instance.customRating, + 'ChannelId': instance.channelId, + 'ChannelName': instance.channelName, + 'Overview': instance.overview, + 'Taglines': instance.taglines, + 'Genres': instance.genres, + 'CommunityRating': instance.communityRating, + 'RunTimeTicks': instance.runTimeTicks, + 'PlayAccess': instance.playAccess, + 'AspectRatio': instance.aspectRatio, + 'ProductionYear': instance.productionYear, + 'Number': instance.number, + 'ChannelNumber': instance.channelNumber, + 'IndexNumber': instance.indexNumber, + 'IndexNumberEnd': instance.indexNumberEnd, + 'ParentIndexNumber': instance.parentIndexNumber, + 'RemoteTrailers': + instance.remoteTrailers?.map((e) => e.toJson()).toList(), + 'ProviderIds': instance.providerIds, + 'IsFolder': instance.isFolder, + 'ParentId': instance.parentId, + 'Type': instance.type, + 'People': instance.people?.map((e) => e.toJson()).toList(), + 'Studios': instance.studios?.map((e) => e.toJson()).toList(), + 'GenreItems': instance.genreItems?.map((e) => e.toJson()).toList(), + 'ParentLogoItemId': instance.parentLogoItemId, + 'ParentBackdropItemId': instance.parentBackdropItemId, + 'ParentBackdropImageTags': instance.parentBackdropImageTags, + 'LocalTrailerCount': instance.localTrailerCount, + 'UserData': instance.userData?.toJson(), + 'RecursiveItemCount': instance.recursiveItemCount, + 'ChildCount': instance.childCount, + 'SeriesName': instance.seriesName, + 'SeriesId': instance.seriesId, + 'SeasonId': instance.seasonId, + 'SpecialFeatureCount': instance.specialFeatureCount, + 'DisplayPreferencesId': instance.displayPreferencesId, + 'Status': instance.status, + 'AirTime': instance.airTime, + 'AirDays': instance.airDays, + 'Tags': instance.tags, + 'PrimaryImageAspectRatio': instance.primaryImageAspectRatio, + 'Artists': instance.artists, + 'ArtistItems': instance.artistItems?.map((e) => e.toJson()).toList(), + 'Album': instance.album, + 'CollectionType': instance.collectionType, + 'DisplayOrder': instance.displayOrder, + 'AlbumId': instance.albumId, + 'AlbumPrimaryImageTag': instance.albumPrimaryImageTag, + 'SeriesPrimaryImageTag': instance.seriesPrimaryImageTag, + 'AlbumArtist': instance.albumArtist, + 'AlbumArtists': instance.albumArtists?.map((e) => e.toJson()).toList(), + 'SeasonName': instance.seasonName, + 'MediaStreams': instance.mediaStreams?.map((e) => e.toJson()).toList(), + 'PartCount': instance.partCount, + 'ImageTags': instance.imageTags, + 'BackdropImageTags': instance.backdropImageTags, + 'ParentLogoImageTag': instance.parentLogoImageTag, + 'ParentArtItemId': instance.parentArtItemId, + 'ParentArtImageTag': instance.parentArtImageTag, + 'SeriesThumbImageTag': instance.seriesThumbImageTag, + 'SeriesStudio': instance.seriesStudio, + 'ParentThumbItemId': instance.parentThumbItemId, + 'ParentThumbImageTag': instance.parentThumbImageTag, + 'ParentPrimaryImageItemId': instance.parentPrimaryImageItemId, + 'ParentPrimaryImageTag': instance.parentPrimaryImageTag, + 'Chapters': instance.chapters?.map((e) => e.toJson()).toList(), + 'LocationType': instance.locationType, + 'MediaType': instance.mediaType, + 'EndDate': instance.endDate, + 'LockedFields': instance.lockedFields, + 'LockData': instance.lockData, + 'Width': instance.width, + 'Height': instance.height, + 'CameraMake': instance.cameraMake, + 'CameraModel': instance.cameraModel, + 'Software': instance.software, + 'ExposureTime': instance.exposureTime, + 'FocalLength': instance.focalLength, + 'ImageOrientation': instance.imageOrientation, + 'Aperture': instance.aperture, + 'ShutterSpeed': instance.shutterSpeed, + 'Latitude': instance.latitude, + 'Longitude': instance.longitude, + 'Altitude': instance.altitude, + 'IsoSpeedRating': instance.isoSpeedRating, + 'SeriesTimerId': instance.seriesTimerId, + 'ChannelPrimaryImageTag': instance.channelPrimaryImageTag, + 'StartDate': instance.startDate, + 'CompletionPercentage': instance.completionPercentage, + 'IsRepeat': instance.isRepeat, + 'EpisodeTitle': instance.episodeTitle, + 'IsMovie': instance.isMovie, + 'IsSports': instance.isSports, + 'IsSeries': instance.isSeries, + 'IsLive': instance.isLive, + 'IsNews': instance.isNews, + 'IsKids': instance.isKids, + 'IsPremiere': instance.isPremiere, + 'TimerId': instance.timerId, + 'CurrentProgram': instance.currentProgram, + 'MovieCount': instance.movieCount, + 'SeriesCount': instance.seriesCount, + 'AlbumCount': instance.albumCount, + 'SongCount': instance.songCount, + 'MusicVideoCount': instance.musicVideoCount, + 'SourceType': instance.sourceType, + 'DateLastMediaAdded': instance.dateLastMediaAdded, + 'EnableMediaSourceDisplay': instance.enableMediaSourceDisplay, + 'CumulativeRunTimeTicks': instance.cumulativeRunTimeTicks, + 'IsPlaceHolder': instance.isPlaceHolder, + 'IsHD': instance.isHD, + 'VideoType': instance.videoType, + 'MediaSourceCount': instance.mediaSourceCount, + 'ScreenshotImageTags': instance.screenshotImageTags, + 'ImageBlurHashes': instance.imageBlurHashes?.toJson(), + 'IsoType': instance.isoType, + 'TrailerCount': instance.trailerCount, + 'ProgramCount': instance.programCount, + 'EpisodeCount': instance.episodeCount, + 'ArtistCount': instance.artistCount, + 'ProgramId': instance.programId, + 'ChannelType': instance.channelType, + 'Audio': instance.audio, + }; + +ExternalUrl _$ExternalUrlFromJson(Map json) => ExternalUrl( + name: json['Name'] as String?, + url: json['Url'] as String?, + ); + +Map _$ExternalUrlToJson(ExternalUrl instance) => + { + 'Name': instance.name, + 'Url': instance.url, + }; + +MediaSourceInfo _$MediaSourceInfoFromJson(Map json) => MediaSourceInfo( + protocol: json['Protocol'] as String, + id: json['Id'] as String?, + path: json['Path'] as String?, + encoderPath: json['EncoderPath'] as String?, + encoderProtocol: json['EncoderProtocol'] as String?, + type: json['Type'] as String, + container: json['Container'] as String?, + size: (json['Size'] as num?)?.toInt(), + name: json['Name'] as String?, + isRemote: json['IsRemote'] as bool, + runTimeTicks: (json['RunTimeTicks'] as num?)?.toInt(), + supportsTranscoding: json['SupportsTranscoding'] as bool, + supportsDirectStream: json['SupportsDirectStream'] as bool, + supportsDirectPlay: json['SupportsDirectPlay'] as bool, + isInfiniteStream: json['IsInfiniteStream'] as bool, + requiresOpening: json['RequiresOpening'] as bool, + openToken: json['OpenToken'] as String?, + requiresClosing: json['RequiresClosing'] as bool, + liveStreamId: json['LiveStreamId'] as String?, + bufferMs: (json['BufferMs'] as num?)?.toInt(), + requiresLooping: json['RequiresLooping'] as bool, + supportsProbing: json['SupportsProbing'] as bool, + video3DFormat: json['Video3DFormat'] as String?, + mediaStreams: (json['MediaStreams'] as List) + .map((e) => MediaStream.fromJson(Map.from(e as Map))) + .toList(), + formats: + (json['Formats'] as List?)?.map((e) => e as String).toList(), + bitrate: (json['Bitrate'] as num?)?.toInt(), + timestamp: json['Timestamp'] as String?, + requiredHttpHeaders: (json['RequiredHttpHeaders'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ), + transcodingUrl: json['TranscodingUrl'] as String?, + transcodingSubProtocol: json['TranscodingSubProtocol'] as String?, + transcodingContainer: json['TranscodingContainer'] as String?, + analyzeDurationMs: (json['AnalyzeDurationMs'] as num?)?.toInt(), + readAtNativeFramerate: json['ReadAtNativeFramerate'] as bool, + defaultAudioStreamIndex: + (json['DefaultAudioStreamIndex'] as num?)?.toInt(), + defaultSubtitleStreamIndex: + (json['DefaultSubtitleStreamIndex'] as num?)?.toInt(), + etag: json['Etag'] as String?, + ignoreDts: json['IgnoreDts'] as bool, + ignoreIndex: json['IgnoreIndex'] as bool, + genPtsInput: json['GenPtsInput'] as bool, + videoType: json['VideoType'] as String?, + isoType: json['IsoType'] as String?, + mediaAttachments: (json['MediaAttachments'] as List?) + ?.map((e) => + MediaAttachment.fromJson(Map.from(e as Map))) + .toList(), + ); + +Map _$MediaSourceInfoToJson(MediaSourceInfo instance) => + { + 'Protocol': instance.protocol, + 'Id': instance.id, + 'Path': instance.path, + 'EncoderPath': instance.encoderPath, + 'EncoderProtocol': instance.encoderProtocol, + 'Type': instance.type, + 'Container': instance.container, + 'Size': instance.size, + 'Name': instance.name, + 'IsRemote': instance.isRemote, + 'RunTimeTicks': instance.runTimeTicks, + 'SupportsTranscoding': instance.supportsTranscoding, + 'SupportsDirectStream': instance.supportsDirectStream, + 'SupportsDirectPlay': instance.supportsDirectPlay, + 'IsInfiniteStream': instance.isInfiniteStream, + 'RequiresOpening': instance.requiresOpening, + 'OpenToken': instance.openToken, + 'RequiresClosing': instance.requiresClosing, + 'LiveStreamId': instance.liveStreamId, + 'BufferMs': instance.bufferMs, + 'RequiresLooping': instance.requiresLooping, + 'SupportsProbing': instance.supportsProbing, + 'Video3DFormat': instance.video3DFormat, + 'MediaStreams': instance.mediaStreams.map((e) => e.toJson()).toList(), + 'Formats': instance.formats, + 'Bitrate': instance.bitrate, + 'Timestamp': instance.timestamp, + 'RequiredHttpHeaders': instance.requiredHttpHeaders, + 'TranscodingUrl': instance.transcodingUrl, + 'TranscodingSubProtocol': instance.transcodingSubProtocol, + 'TranscodingContainer': instance.transcodingContainer, + 'AnalyzeDurationMs': instance.analyzeDurationMs, + 'ReadAtNativeFramerate': instance.readAtNativeFramerate, + 'DefaultAudioStreamIndex': instance.defaultAudioStreamIndex, + 'DefaultSubtitleStreamIndex': instance.defaultSubtitleStreamIndex, + 'Etag': instance.etag, + 'IgnoreDts': instance.ignoreDts, + 'IgnoreIndex': instance.ignoreIndex, + 'GenPtsInput': instance.genPtsInput, + 'VideoType': instance.videoType, + 'IsoType': instance.isoType, + 'MediaAttachments': + instance.mediaAttachments?.map((e) => e.toJson()).toList(), + }; + +MediaStream _$MediaStreamFromJson(Map json) => MediaStream( + codec: json['Codec'] as String?, + codecTag: json['CodecTag'] as String?, + language: json['Language'] as String?, + colorTransfer: json['ColorTransfer'] as String?, + colorPrimaries: json['ColorPrimaries'] as String?, + colorSpace: json['ColorSpace'] as String?, + comment: json['Comment'] as String?, + timeBase: json['TimeBase'] as String?, + codecTimeBase: json['CodecTimeBase'] as String?, + title: json['Title'] as String?, + videoRange: json['VideoRange'] as String?, + displayTitle: json['DisplayTitle'] as String?, + nalLengthSize: json['NalLengthSize'] as String?, + isInterlaced: json['IsInterlaced'] as bool, + isAVC: json['IsAVC'] as bool?, + channelLayout: json['ChannelLayout'] as String?, + bitRate: (json['BitRate'] as num?)?.toInt(), + bitDepth: (json['BitDepth'] as num?)?.toInt(), + refFrames: (json['RefFrames'] as num?)?.toInt(), + packetLength: (json['PacketLength'] as num?)?.toInt(), + channels: (json['Channels'] as num?)?.toInt(), + sampleRate: (json['SampleRate'] as num?)?.toInt(), + isDefault: json['IsDefault'] as bool, + isForced: json['IsForced'] as bool, + height: (json['Height'] as num?)?.toInt(), + width: (json['Width'] as num?)?.toInt(), + averageFrameRate: (json['AverageFrameRate'] as num?)?.toDouble(), + realFrameRate: (json['RealFrameRate'] as num?)?.toDouble(), + profile: json['Profile'] as String?, + type: json['Type'] as String, + aspectRatio: json['AspectRatio'] as String?, + index: (json['Index'] as num).toInt(), + score: (json['Score'] as num?)?.toInt(), + isExternal: json['IsExternal'] as bool, + deliveryMethod: json['DeliveryMethod'] as String?, + deliveryUrl: json['DeliveryUrl'] as String?, + isExternalUrl: json['IsExternalUrl'] as bool?, + isTextSubtitleStream: json['IsTextSubtitleStream'] as bool, + supportsExternalStream: json['SupportsExternalStream'] as bool, + path: json['Path'] as String?, + pixelFormat: json['PixelFormat'] as String?, + level: (json['Level'] as num?)?.toDouble(), + isAnamorphic: json['IsAnamorphic'] as bool?, + ) + ..colorRange = json['ColorRange'] as String? + ..localizedUndefined = json['LocalizedUndefined'] as String? + ..localizedDefault = json['LocalizedDefault'] as String? + ..localizedForced = json['LocalizedForced'] as String?; + +Map _$MediaStreamToJson(MediaStream instance) => + { + 'Codec': instance.codec, + 'CodecTag': instance.codecTag, + 'Language': instance.language, + 'ColorTransfer': instance.colorTransfer, + 'ColorPrimaries': instance.colorPrimaries, + 'ColorSpace': instance.colorSpace, + 'Comment': instance.comment, + 'TimeBase': instance.timeBase, + 'CodecTimeBase': instance.codecTimeBase, + 'Title': instance.title, + 'VideoRange': instance.videoRange, + 'DisplayTitle': instance.displayTitle, + 'NalLengthSize': instance.nalLengthSize, + 'IsInterlaced': instance.isInterlaced, + 'IsAVC': instance.isAVC, + 'ChannelLayout': instance.channelLayout, + 'BitRate': instance.bitRate, + 'BitDepth': instance.bitDepth, + 'RefFrames': instance.refFrames, + 'PacketLength': instance.packetLength, + 'Channels': instance.channels, + 'SampleRate': instance.sampleRate, + 'IsDefault': instance.isDefault, + 'IsForced': instance.isForced, + 'Height': instance.height, + 'Width': instance.width, + 'AverageFrameRate': instance.averageFrameRate, + 'RealFrameRate': instance.realFrameRate, + 'Profile': instance.profile, + 'Type': instance.type, + 'AspectRatio': instance.aspectRatio, + 'Index': instance.index, + 'Score': instance.score, + 'IsExternal': instance.isExternal, + 'DeliveryMethod': instance.deliveryMethod, + 'DeliveryUrl': instance.deliveryUrl, + 'IsExternalUrl': instance.isExternalUrl, + 'IsTextSubtitleStream': instance.isTextSubtitleStream, + 'SupportsExternalStream': instance.supportsExternalStream, + 'Path': instance.path, + 'PixelFormat': instance.pixelFormat, + 'Level': instance.level, + 'IsAnamorphic': instance.isAnamorphic, + 'ColorRange': instance.colorRange, + 'LocalizedUndefined': instance.localizedUndefined, + 'LocalizedDefault': instance.localizedDefault, + 'LocalizedForced': instance.localizedForced, + }; + +MediaUrl _$MediaUrlFromJson(Map json) => MediaUrl( + url: json['Url'] as String?, + name: json['Name'] as String?, + ); + +Map _$MediaUrlToJson(MediaUrl instance) => { + 'Url': instance.url, + 'Name': instance.name, + }; + +BaseItemPerson _$BaseItemPersonFromJson(Map json) => BaseItemPerson( + name: json['Name'] as String?, + id: json['Id'] as String?, + role: json['Role'] as String?, + type: json['Type'] as String?, + primaryImageTag: json['PrimaryImageTag'] as String?, + )..imageBlurHashes = json['ImageBlurHashes'] == null + ? null + : ImageBlurHashes.fromJson( + Map.from(json['ImageBlurHashes'] as Map)); + +Map _$BaseItemPersonToJson(BaseItemPerson instance) => + { + 'Name': instance.name, + 'Id': instance.id, + 'Role': instance.role, + 'Type': instance.type, + 'PrimaryImageTag': instance.primaryImageTag, + 'ImageBlurHashes': instance.imageBlurHashes?.toJson(), + }; + +NameLongIdPair _$NameLongIdPairFromJson(Map json) => NameLongIdPair( + name: json['Name'] as String?, + id: json['Id'] as String, + ); + +Map _$NameLongIdPairToJson(NameLongIdPair instance) => + { + 'Name': instance.name, + 'Id': instance.id, + }; + +UserItemDataDto _$UserItemDataDtoFromJson(Map json) => UserItemDataDto( + rating: (json['Rating'] as num?)?.toDouble(), + playedPercentage: (json['PlayedPercentage'] as num?)?.toDouble(), + unplayedItemCount: (json['UnplayedItemCount'] as num?)?.toInt(), + playbackPositionTicks: (json['PlaybackPositionTicks'] as num).toInt(), + playCount: (json['PlayCount'] as num).toInt(), + isFavorite: json['IsFavorite'] as bool, + likes: json['Likes'] as bool?, + lastPlayedDate: json['LastPlayedDate'] as String?, + played: json['Played'] as bool, + key: json['Key'] as String?, + itemId: json['ItemId'] as String?, + ); + +Map _$UserItemDataDtoToJson(UserItemDataDto instance) => + { + 'Rating': instance.rating, + 'PlayedPercentage': instance.playedPercentage, + 'UnplayedItemCount': instance.unplayedItemCount, + 'PlaybackPositionTicks': instance.playbackPositionTicks, + 'PlayCount': instance.playCount, + 'IsFavorite': instance.isFavorite, + 'Likes': instance.likes, + 'LastPlayedDate': instance.lastPlayedDate, + 'Played': instance.played, + 'Key': instance.key, + 'ItemId': instance.itemId, + }; + +NameIdPair _$NameIdPairFromJson(Map json) => NameIdPair( + name: json['Name'] as String?, + id: json['Id'] as String, + ); + +Map _$NameIdPairToJson(NameIdPair instance) => + { + 'Name': instance.name, + 'Id': instance.id, + }; + +ChapterInfo _$ChapterInfoFromJson(Map json) => ChapterInfo( + startPositionTicks: (json['StartPositionTicks'] as num).toInt(), + name: json['Name'] as String?, + imageTag: json['ImageTag'] as String?, + imagePath: json['ImagePath'] as String?, + imageDateModified: json['ImageDateModified'] as String, + ); + +Map _$ChapterInfoToJson(ChapterInfo instance) => + { + 'StartPositionTicks': instance.startPositionTicks, + 'Name': instance.name, + 'ImageTag': instance.imageTag, + 'ImagePath': instance.imagePath, + 'ImageDateModified': instance.imageDateModified, + }; + +QueryResult_BaseItemDto _$QueryResult_BaseItemDtoFromJson(Map json) => + QueryResult_BaseItemDto( + items: (json['Items'] as List?) + ?.map( + (e) => BaseItemDto.fromJson(Map.from(e as Map))) + .toList(), + totalRecordCount: (json['TotalRecordCount'] as num).toInt(), + startIndex: (json['StartIndex'] as num).toInt(), + ); + +Map _$QueryResult_BaseItemDtoToJson( + QueryResult_BaseItemDto instance) => + { + 'Items': instance.items?.map((e) => e.toJson()).toList(), + 'TotalRecordCount': instance.totalRecordCount, + 'StartIndex': instance.startIndex, + }; + +PlaybackInfoResponse _$PlaybackInfoResponseFromJson(Map json) => + PlaybackInfoResponse( + mediaSources: (json['MediaSources'] as List?) + ?.map((e) => + MediaSourceInfo.fromJson(Map.from(e as Map))) + .toList(), + playSessionId: json['PlaySessionId'] as String?, + errorCode: json['ErrorCode'] as String?, + ); + +Map _$PlaybackInfoResponseToJson( + PlaybackInfoResponse instance) => + { + 'MediaSources': instance.mediaSources?.map((e) => e.toJson()).toList(), + 'PlaySessionId': instance.playSessionId, + 'ErrorCode': instance.errorCode, + }; + +PlaybackProgressInfo _$PlaybackProgressInfoFromJson(Map json) => + PlaybackProgressInfo( + canSeek: json['CanSeek'] as bool? ?? true, + item: json['Item'] == null + ? null + : BaseItemDto.fromJson( + Map.from(json['Item'] as Map)), + itemId: json['ItemId'] as String, + sessionId: json['SessionId'] as String?, + mediaSourceId: json['MediaSourceId'] as String?, + audioStreamIndex: (json['AudioStreamIndex'] as num?)?.toInt(), + subtitleStreamIndex: (json['SubtitleStreamIndex'] as num?)?.toInt(), + isPaused: json['IsPaused'] as bool, + isMuted: json['IsMuted'] as bool, + positionTicks: (json['PositionTicks'] as num?)?.toInt(), + playbackStartTimeTicks: (json['PlaybackStartTimeTicks'] as num?)?.toInt(), + volumeLevel: (json['VolumeLevel'] as num?)?.toInt(), + brightness: (json['Brightness'] as num?)?.toInt(), + aspectRatio: json['AspectRatio'] as String?, + playMethod: json['PlayMethod'] as String? ?? "DirectPlay", + liveStreamId: json['LiveStreamId'] as String?, + playSessionId: json['PlaySessionId'] as String?, + repeatMode: json['RepeatMode'] as String, + nowPlayingQueue: (json['NowPlayingQueue'] as List?) + ?.map((e) => QueueItem.fromJson(Map.from(e as Map))) + .toList(), + playlistItemId: json['PlaylistItemId'] as String?, + ); + +Map _$PlaybackProgressInfoToJson( + PlaybackProgressInfo instance) => + { + 'CanSeek': instance.canSeek, + 'Item': instance.item?.toJson(), + 'ItemId': instance.itemId, + 'SessionId': instance.sessionId, + 'MediaSourceId': instance.mediaSourceId, + 'AudioStreamIndex': instance.audioStreamIndex, + 'SubtitleStreamIndex': instance.subtitleStreamIndex, + 'IsPaused': instance.isPaused, + 'IsMuted': instance.isMuted, + 'PositionTicks': instance.positionTicks, + 'PlaybackStartTimeTicks': instance.playbackStartTimeTicks, + 'VolumeLevel': instance.volumeLevel, + 'Brightness': instance.brightness, + 'AspectRatio': instance.aspectRatio, + 'PlayMethod': instance.playMethod, + 'LiveStreamId': instance.liveStreamId, + 'PlaySessionId': instance.playSessionId, + 'RepeatMode': instance.repeatMode, + 'NowPlayingQueue': + instance.nowPlayingQueue?.map((e) => e.toJson()).toList(), + 'PlaylistItemId': instance.playlistItemId, + }; + +ImageBlurHashes _$ImageBlurHashesFromJson(Map json) => ImageBlurHashes( + primary: (json['Primary'] as Map?)?.map( + (k, e) => MapEntry(k as String, e as String), + ), + art: (json['Art'] as Map?)?.map( + (k, e) => MapEntry(k as String, e as String), + ), + backdrop: (json['Backdrop'] as Map?)?.map( + (k, e) => MapEntry(k as String, e as String), + ), + banner: (json['Banner'] as Map?)?.map( + (k, e) => MapEntry(k as String, e as String), + ), + logo: (json['Logo'] as Map?)?.map( + (k, e) => MapEntry(k as String, e as String), + ), + thumb: (json['Thumb'] as Map?)?.map( + (k, e) => MapEntry(k as String, e as String), + ), + disc: (json['Disc'] as Map?)?.map( + (k, e) => MapEntry(k as String, e as String), + ), + box: (json['Box'] as Map?)?.map( + (k, e) => MapEntry(k as String, e as String), + ), + screenshot: (json['Screenshot'] as Map?)?.map( + (k, e) => MapEntry(k as String, e as String), + ), + menu: (json['Menu'] as Map?)?.map( + (k, e) => MapEntry(k as String, e as String), + ), + chapter: (json['Chapter'] as Map?)?.map( + (k, e) => MapEntry(k as String, e as String), + ), + boxRear: (json['BoxRear'] as Map?)?.map( + (k, e) => MapEntry(k as String, e as String), + ), + profile: (json['Profile'] as Map?)?.map( + (k, e) => MapEntry(k as String, e as String), + ), + ); + +Map _$ImageBlurHashesToJson(ImageBlurHashes instance) => + { + 'Primary': instance.primary, + 'Art': instance.art, + 'Backdrop': instance.backdrop, + 'Banner': instance.banner, + 'Logo': instance.logo, + 'Thumb': instance.thumb, + 'Disc': instance.disc, + 'Box': instance.box, + 'Screenshot': instance.screenshot, + 'Menu': instance.menu, + 'Chapter': instance.chapter, + 'BoxRear': instance.boxRear, + 'Profile': instance.profile, + }; + +MediaAttachment _$MediaAttachmentFromJson(Map json) => MediaAttachment( + codec: json['Codec'] as String?, + codecTag: json['CodecTag'] as String?, + comment: json['Comment'] as String?, + index: (json['Index'] as num).toInt(), + fileName: json['FileName'] as String?, + mimeType: json['MimeType'] as String?, + deliveryUrl: json['DeliveryUrl'] as String?, + ); + +Map _$MediaAttachmentToJson(MediaAttachment instance) => + { + 'Codec': instance.codec, + 'CodecTag': instance.codecTag, + 'Comment': instance.comment, + 'Index': instance.index, + 'FileName': instance.fileName, + 'MimeType': instance.mimeType, + 'DeliveryUrl': instance.deliveryUrl, + }; + +BaseItem _$BaseItemFromJson(Map json) => BaseItem( + size: (json['Size'] as num?)?.toInt(), + container: json['Container'] as String?, + dateLastSaved: json['DateLastSaved'] as String?, + remoteTrailers: (json['RemoteTrailers'] as List?) + ?.map((e) => MediaUrl.fromJson(Map.from(e as Map))) + .toList(), + isHD: json['IsHD'] as bool, + isShortcut: json['IsShortcut'] as bool, + shortcutPath: json['ShortcutPath'] as String?, + width: (json['Width'] as num?)?.toInt(), + height: (json['Height'] as num?)?.toInt(), + extraIds: (json['ExtraIds'] as List?) + ?.map((e) => e as String) + .toList(), + supportsExternalTransfer: json['SupportsExternalTransfer'] as bool, + ); + +Map _$BaseItemToJson(BaseItem instance) => { + 'Size': instance.size, + 'Container': instance.container, + 'DateLastSaved': instance.dateLastSaved, + 'RemoteTrailers': + instance.remoteTrailers?.map((e) => e.toJson()).toList(), + 'IsHD': instance.isHD, + 'IsShortcut': instance.isShortcut, + 'ShortcutPath': instance.shortcutPath, + 'Width': instance.width, + 'Height': instance.height, + 'ExtraIds': instance.extraIds, + 'SupportsExternalTransfer': instance.supportsExternalTransfer, + }; + +QueueItem _$QueueItemFromJson(Map json) => QueueItem( + id: json['Id'] as String, + playlistItemId: json['PlaylistItemId'] as String?, + ); + +Map _$QueueItemToJson(QueueItem instance) => { + 'Id': instance.id, + 'PlaylistItemId': instance.playlistItemId, + }; + +NewPlaylist _$NewPlaylistFromJson(Map json) => NewPlaylist( + name: json['Name'] as String?, + ids: (json['Ids'] as List).map((e) => e as String).toList(), + userId: json['UserId'] as String?, + mediaType: json['MediaType'] as String?, + ); + +Map _$NewPlaylistToJson(NewPlaylist instance) => + { + 'Name': instance.name, + 'Ids': instance.ids, + 'UserId': instance.userId, + 'MediaType': instance.mediaType, + }; + +NewPlaylistResponse _$NewPlaylistResponseFromJson(Map json) => + NewPlaylistResponse( + id: json['Id'] as String?, + ); + +Map _$NewPlaylistResponseToJson( + NewPlaylistResponse instance) => + { + 'Id': instance.id, + }; diff --git a/lib/models/locale_adapter.dart b/lib/models/locale_adapter.dart new file mode 100644 index 0000000..5d3b807 --- /dev/null +++ b/lib/models/locale_adapter.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; + +class LocaleAdapter extends TypeAdapter { + @override + int get typeId => 42; + + @override + Locale read(BinaryReader reader) { + final localeList = reader.readStringList(); + + final languageCode = localeList[0]; + + // scriptCode and countryCode are empty strings when null (see write) + final scriptCode = localeList[1] == "" ? null : localeList[1]; + final countryCode = localeList[2] == "" ? null : localeList[2]; + + return Locale.fromSubtags( + languageCode: languageCode, + scriptCode: scriptCode, + countryCode: countryCode, + ); + } + + @override + void write(BinaryWriter writer, Locale obj) { + writer.writeStringList([ + obj.languageCode, + obj.scriptCode ?? "", + obj.countryCode ?? "", + ]); + } +} diff --git a/lib/models/theme_mode_adapter.dart b/lib/models/theme_mode_adapter.dart new file mode 100644 index 0000000..db2f5c5 --- /dev/null +++ b/lib/models/theme_mode_adapter.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; + +class ThemeModeAdapter extends TypeAdapter { + @override + final int typeId = 41; + + @override + ThemeMode read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return ThemeMode.system; + case 1: + return ThemeMode.light; + case 2: + return ThemeMode.dark; + default: + return ThemeMode.system; + } + } + + @override + void write(BinaryWriter writer, ThemeMode obj) { + switch (obj) { + case ThemeMode.system: + writer.writeByte(0); + break; + case ThemeMode.light: + writer.writeByte(1); + break; + case ThemeMode.dark: + writer.writeByte(2); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ThemeModeAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/screens/add_download_location_screen.dart b/lib/screens/add_download_location_screen.dart new file mode 100644 index 0000000..22520b9 --- /dev/null +++ b/lib/screens/add_download_location_screen.dart @@ -0,0 +1,147 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:provider/provider.dart'; + +import '../components/AddDownloadLocationScreen/custom_download_location_form.dart'; +import '../components/AddDownloadLocationScreen/app_directory_location_form.dart'; +import '../models/finamp_models.dart'; +import '../services/finamp_settings_helper.dart'; + +class AddDownloadLocationScreen extends StatefulWidget { + const AddDownloadLocationScreen({Key? key}) : super(key: key); + + static const routeName = "/settings/downloadlocations/add"; + + @override + State createState() => + _AddDownloadLocationScreenState(); +} + +class _AddDownloadLocationScreenState extends State + with SingleTickerProviderStateMixin { + final customLocationFormKey = GlobalKey(); + final appDirectoryFormKey = GlobalKey(); + + late TabController _tabController; + + @override + void initState() { + super.initState(); + // Since we can't initialise tabs before initState we need to awkwardly + // provide the length directly + _tabController = + TabController(vsync: this, length: Platform.isAndroid ? 2 : 1); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final tabs = Platform.isAndroid + ? [ + Tab( + text: AppLocalizations.of(context)!.customLocation.toUpperCase(), + ), + Tab( + text: AppLocalizations.of(context)!.appDirectory.toUpperCase(), + ), + ] + : [ + Tab( + text: AppLocalizations.of(context)!.customLocation.toUpperCase(), + ), + ]; + return Provider( + create: (_) => NewDownloadLocation( + name: null, + deletable: true, + path: null, + useHumanReadableNames: null, + ), + builder: (context, _) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.addDownloadLocation), + bottom: TabBar( + controller: _tabController, + tabs: tabs, + ), + ), + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.check), + onPressed: () { + bool isValidated = false; + + // If _tabController.index is 0, we are on the custom location tab. + // If not, we are on the app directory tab. + if (_tabController.index == 0) { + if (customLocationFormKey.currentState?.validate() == true) { + customLocationFormKey.currentState!.save(); + // If we're saving to a custom location, we want to use human readable names. + // With app dir locations, we don't use human readable names. + context.read().useHumanReadableNames = + true; + isValidated = true; + } + } else { + if (appDirectoryFormKey.currentState?.validate() == true) { + appDirectoryFormKey.currentState!.save(); + context.read().useHumanReadableNames = + false; + isValidated = true; + } + } + + // We set a variable called isValidated so that we don't have to copy this logic into each validate() + if (isValidated) { + final newDownloadLocation = context.read(); + + // We don't use DownloadLocation when initially getting the + // values because DownloadLocation doesn't have nullable values. + // At this point, the NewDownloadLocation shouldn't have any + // null values. + final downloadLocation = DownloadLocation.create( + name: newDownloadLocation.name!, + path: newDownloadLocation.path!, + useHumanReadableNames: + newDownloadLocation.useHumanReadableNames!, + deletable: newDownloadLocation.deletable, + ); + + FinampSettingsHelper.addDownloadLocation(downloadLocation); + Navigator.of(context).pop(); + } + }, + ), + body: TabBarView( + controller: _tabController, + children: [ + Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: CustomDownloadLocationForm( + formKey: customLocationFormKey, + ), + ), + ), + if (Platform.isAndroid) + Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: + AppDirectoryLocationForm(formKey: appDirectoryFormKey), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/screens/add_to_playlist_screen.dart b/lib/screens/add_to_playlist_screen.dart new file mode 100644 index 0000000..9234e43 --- /dev/null +++ b/lib/screens/add_to_playlist_screen.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../components/AddToPlaylistScreen/add_to_playlist_list.dart'; +import '../components/AddToPlaylistScreen/new_playlist_dialog.dart'; + +class AddToPlaylistScreen extends StatefulWidget { + const AddToPlaylistScreen({Key? key}) : super(key: key); + + static const routeName = "/music/addtoplaylist"; + + @override + State createState() => _AddToPlaylistScreenState(); +} + +class _AddToPlaylistScreenState extends State { + @override + Widget build(BuildContext context) { + final itemId = ModalRoute.of(context)!.settings.arguments as String; + + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.addToPlaylistTitle), + ), + body: AddToPlaylistList( + itemToAddId: itemId, + ), + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.add), + onPressed: () async { + // The dialog returns true if a playlist is created. If this is the + // case, we also pop this page. It will return false if the user + // cancels the dialog. + final result = await showDialog( + context: context, + builder: (context) => NewPlaylistDialog(itemToAdd: itemId), + ); + + if (!mounted) return; + + if (result == true) { + Navigator.of(context).pop(); + } + }, + ), + ); + } +} diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart new file mode 100644 index 0000000..063bb84 --- /dev/null +++ b/lib/screens/album_screen.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; +import 'package:hive/hive.dart'; + +import '../models/jellyfin_models.dart'; +import '../models/finamp_models.dart'; +import '../services/jellyfin_api_helper.dart'; +import '../services/finamp_settings_helper.dart'; +import '../services/downloads_helper.dart'; +import '../components/now_playing_bar.dart'; +import '../components/AlbumScreen/album_screen_content.dart'; +import '../services/music_player_background_task.dart'; + +class AlbumScreen extends StatefulWidget { + const AlbumScreen({ + Key? key, + this.parent, + }) : super(key: key); + + static const routeName = "/music/album"; + + /// The album to show. Can also be provided as an argument in a named route + final BaseItemDto? parent; + + @override + State createState() => _AlbumScreenState(); +} + +class _AlbumScreenState extends State { + Future?>? albumScreenContentFuture; + JellyfinApiHelper jellyfinApiHelper = GetIt.instance(); + final audioHandler = GetIt.instance(); + + @override + Widget build(BuildContext context) { + final BaseItemDto parent = widget.parent ?? + ModalRoute.of(context)!.settings.arguments as BaseItemDto; + + return Scaffold( + body: ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (context, box, widget) { + bool isOffline = box.get("FinampSettings")?.isOffline ?? false; + + if (isOffline) { + final downloadsHelper = GetIt.instance(); + + // The downloadedParent won't be null here if we've already + // navigated to it in offline mode + final downloadedParent = + downloadsHelper.getDownloadedParent(parent.id)!; + + return AlbumScreenContent( + parent: downloadedParent.item, + children: downloadedParent.downloadedChildren.values.toList(), + ); + } else { + albumScreenContentFuture ??= jellyfinApiHelper.getItems( + parentItem: parent, + sortBy: "ParentIndexNumber,IndexNumber,SortName", + includeItemTypes: "Audio", + isGenres: false, + ); + + return FutureBuilder?>( + future: albumScreenContentFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + final List items = snapshot.data!; + + return AlbumScreenContent(parent: parent, children: items); + } else if (snapshot.hasError) { + return CustomScrollView( + physics: const NeverScrollableScrollPhysics(), + slivers: [ + SliverAppBar( + title: Text(AppLocalizations.of(context)!.error), + ), + SliverFillRemaining( + child: Center(child: Text(snapshot.error.toString())), + ) + ], + ); + } else { + // We return all of this so that we can have an app bar while loading. + // This is especially important for iOS, where there isn't a hardware back button. + return CustomScrollView( + physics: const NeverScrollableScrollPhysics(), + slivers: [ + SliverAppBar( + title: Text(parent.name ?? + AppLocalizations.of(context)!.unknownName), + ), + const SliverFillRemaining( + child: Center( + child: CircularProgressIndicator.adaptive(), + ), + ) + ], + ); + } + }, + ); + } + }, + ), + bottomNavigationBar: const NowPlayingBar(), + ); + } +} diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart new file mode 100644 index 0000000..e25b7fc --- /dev/null +++ b/lib/screens/artist_screen.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +import '../models/jellyfin_models.dart'; +import '../models/finamp_models.dart'; +import '../components/ArtistScreen/artist_download_button.dart'; +import '../components/MusicScreen/music_screen_tab_view.dart'; +import '../components/now_playing_bar.dart'; +import '../components/favourite_button.dart'; +import '../components/ArtistScreen/artist_play_button.dart'; +import '../components/ArtistScreen/artist_shuffle_button.dart'; + +class ArtistScreen extends StatelessWidget { + const ArtistScreen({ + Key? key, + this.widgetArtist, + }) : super(key: key); + + static const routeName = "/music/artist"; + + /// The artist to show. Can also be provided as an argument in a named route + final BaseItemDto? widgetArtist; + + @override + Widget build(BuildContext context) { + final BaseItemDto artist = widgetArtist ?? + ModalRoute.of(context)!.settings.arguments as BaseItemDto; + + return Scaffold( + appBar: AppBar( + title: Text(artist.name ?? "Unknown Name"), + actions: [ + // this screen is also used for genres, which can't be favorited + if (artist.type != "MusicGenre") ArtistPlayButton(artist: artist), + if (artist.type != "MusicGenre") ArtistShuffleButton(artist: artist), + if (artist.type != "MusicGenre") FavoriteButton(item: artist), + ArtistDownloadButton(artist: artist) + ], + ), + body: MusicScreenTabView( + tabContentType: TabContentType.albums, + parentItem: artist, + isFavourite: false, + sortBy: SortBy.premiereDate, + albumArtist: artist.name, + ), + bottomNavigationBar: const NowPlayingBar(), + ); + } +} diff --git a/lib/screens/audio_service_settings_screen.dart b/lib/screens/audio_service_settings_screen.dart new file mode 100644 index 0000000..53d5f98 --- /dev/null +++ b/lib/screens/audio_service_settings_screen.dart @@ -0,0 +1,32 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../components/AudioServiceSettingsScreen/buffer_duration_list_tile.dart'; +import '../components/AudioServiceSettingsScreen/stop_foreground_selector.dart'; +import '../components/AudioServiceSettingsScreen/song_shuffle_item_count_editor.dart'; + +class AudioServiceSettingsScreen extends StatelessWidget { + const AudioServiceSettingsScreen({Key? key}) : super(key: key); + + static const routeName = "/settings/audioservice"; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.audioService), + ), + body: Scrollbar( + child: ListView( + children: [ + if (Platform.isAndroid) const StopForegroundSelector(), + const SongShuffleItemCountEditor(), + const BufferDurationListTile(), + ], + ), + ), + ); + } +} diff --git a/lib/screens/downloads_error_screen.dart b/lib/screens/downloads_error_screen.dart new file mode 100644 index 0000000..1af9167 --- /dev/null +++ b/lib/screens/downloads_error_screen.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; + +import '../components/DownloadsErrorScreen/download_error_list.dart'; +import '../services/downloads_helper.dart'; + +class DownloadsErrorScreen extends StatelessWidget { + const DownloadsErrorScreen({Key? key}) : super(key: key); + + static const routeName = "/downloads/errors"; + + @override + Widget build(BuildContext context) { + final downloadsHelper = GetIt.instance(); + + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.downloadErrorsTitle), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () async { + final scaffoldMessenger = ScaffoldMessenger.of(context); + final appLocalizations = AppLocalizations.of(context); + + final redownloaded = await downloadsHelper.redownloadFailed(); + + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + appLocalizations!.redownloadedItems(redownloaded)), + ), + ); + }) + ]), + body: const DownloadErrorList(), + ); + } +} diff --git a/lib/screens/downloads_screen.dart b/lib/screens/downloads_screen.dart new file mode 100644 index 0000000..73de5f0 --- /dev/null +++ b/lib/screens/downloads_screen.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../components/DownloadsScreen/downloads_overview.dart'; +import '../components/DownloadsScreen/downloaded_albums_list.dart'; +import '../components/DownloadsScreen/download_error_screen_button.dart'; +import '../components/DownloadsScreen/download_missing_images_button.dart'; +import '../components/DownloadsScreen/sync_downloaded_playlists.dart'; + +class DownloadsScreen extends StatelessWidget { + const DownloadsScreen({Key? key}) : super(key: key); + + static const routeName = "/downloads"; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.downloads), + actions: [ + SyncDownloadedAlbumsOrPlaylistsButton(), + const DownloadMissingImagesButton(), + const DownloadErrorScreenButton() + ], + ), + body: Scrollbar( + child: CustomScrollView( + slivers: [ + SliverList( + delegate: SliverChildListDelegate([ + const Padding( + // We don't have bottom padding here since the divider already provides bottom padding + padding: EdgeInsets.fromLTRB(8, 8, 8, 0), + child: DownloadsOverview(), + ), + const Divider(), + ]), + ), + const DownloadedAlbumsList(), + // CurrentDownloadsList(), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/downloads_settings_screen.dart b/lib/screens/downloads_settings_screen.dart new file mode 100644 index 0000000..6093bfd --- /dev/null +++ b/lib/screens/downloads_settings_screen.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import 'add_download_location_screen.dart'; +import '../components/DownloadLocationSettingsScreen/download_location_list.dart'; + +class DownloadsSettingsScreen extends StatelessWidget { + const DownloadsSettingsScreen({Key? key}) : super(key: key); + + static const routeName = "/settings/downloadlocations"; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.downloadLocations), + ), + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.add), + onPressed: () => Navigator.of(context) + .pushNamed(AddDownloadLocationScreen.routeName), + ), + body: const DownloadLocationList(), + ); + } +} diff --git a/lib/screens/interaction_settings_screen.dart b/lib/screens/interaction_settings_screen.dart new file mode 100644 index 0000000..c3ede10 --- /dev/null +++ b/lib/screens/interaction_settings_screen.dart @@ -0,0 +1,32 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../components/InteractionSettingsScreen/swipe_insert_queue_next_selector.dart'; +import '../components/InteractionSettingsScreen/FastScrollSelector.dart'; +import '../components/InteractionSettingsScreen/disable_gestures.dart'; + +class InteractionSettingsScreen extends StatelessWidget { + const InteractionSettingsScreen({Key? key}) : super(key: key); + + static const routeName = "/settings/interactions"; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.interactions), + ), + body: Scrollbar( + child: ListView( + children: [ + const SwipeInsertQueueNextSelector(), + const FastScrollSelector(), + const DisableGestureSelector(), + ], + ), + ), + ); + } +} diff --git a/lib/screens/language_selection_screen.dart b/lib/screens/language_selection_screen.dart new file mode 100644 index 0000000..e58324b --- /dev/null +++ b/lib/screens/language_selection_screen.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../components/LanguageSelectionScreen/language_list.dart'; + +class LanguageSelectionScreen extends StatelessWidget { + const LanguageSelectionScreen({super.key}); + + static const routeName = "/settings/language"; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.language), + ), + body: const LanguageList(), + ); + } +} diff --git a/lib/screens/layout_settings_screen.dart b/lib/screens/layout_settings_screen.dart new file mode 100644 index 0000000..2c02526 --- /dev/null +++ b/lib/screens/layout_settings_screen.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../components/LayoutSettingsScreen/theme_selector.dart'; +import 'tabs_settings_screen.dart'; +import '../components/LayoutSettingsScreen/content_grid_view_cross_axis_count_list_tile.dart'; +import '../components/LayoutSettingsScreen/content_view_type_dropdown_list_tile.dart'; +import '../components/LayoutSettingsScreen/show_text_on_grid_view_selector.dart'; +import '../components/LayoutSettingsScreen/show_cover_as_player_background_selector.dart'; +import '../components/LayoutSettingsScreen/hide_song_artists_if_same_as_album_artists_selector.dart'; + +class LayoutSettingsScreen extends StatelessWidget { + const LayoutSettingsScreen({Key? key}) : super(key: key); + + static const routeName = "/settings/layout"; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.layoutAndTheme), + ), + body: ListView( + children: [ + const ContentViewTypeDropdownListTile(), + for (final type in ContentGridViewCrossAxisCountType.values) + ContentGridViewCrossAxisCountListTile(type: type), + const ShowTextOnGridViewSelector(), + const ShowCoverAsPlayerBackgroundSelector(), + const HideSongArtistsIfSameAsAlbumArtistsSelector(), + const ThemeSelector(), + const Divider(), + ListTile( + leading: const Icon(Icons.tab), + title: Text(AppLocalizations.of(context)!.tabs), + onTap: () => + Navigator.of(context).pushNamed(TabsSettingsScreen.routeName), + ), + ], + ), + ); + } +} diff --git a/lib/screens/logs_screen.dart b/lib/screens/logs_screen.dart new file mode 100644 index 0000000..1b2a678 --- /dev/null +++ b/lib/screens/logs_screen.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../components/LogsScreen/copy_logs_button.dart'; +import '../components/LogsScreen/logs_view.dart'; +import '../components/LogsScreen/share_logs_button.dart'; + +class LogsScreen extends StatelessWidget { + const LogsScreen({Key? key}) : super(key: key); + + static const routeName = "/logs"; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.logs), + actions: const [ + ShareLogsButton(), + CopyLogsButton(), + ], + ), + body: const LogsView(), + ); + } +} diff --git a/lib/screens/music_screen.dart b/lib/screens/music_screen.dart new file mode 100644 index 0000000..d2aa0ef --- /dev/null +++ b/lib/screens/music_screen.dart @@ -0,0 +1,289 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; +import 'package:hive/hive.dart'; +import 'package:logging/logging.dart'; + +import '../models/finamp_models.dart'; +import '../services/finamp_settings_helper.dart'; +import '../services/audio_service_helper.dart'; +import '../services/finamp_user_helper.dart'; +import '../components/MusicScreen/music_screen_tab_view.dart'; +import '../components/MusicScreen/music_screen_drawer.dart'; +import '../components/MusicScreen/sort_by_menu_button.dart'; +import '../components/MusicScreen/sort_order_button.dart'; +import '../components/now_playing_bar.dart'; +import '../components/error_snackbar.dart'; +import '../services/jellyfin_api_helper.dart'; + +class MusicScreen extends StatefulWidget { + const MusicScreen({Key? key}) : super(key: key); + + static const routeName = "/music"; + + @override + State createState() => _MusicScreenState(); +} + +class _MusicScreenState extends State + with TickerProviderStateMixin { + bool isSearching = false; + bool _showShuffleFab = false; + TextEditingController textEditingController = TextEditingController(); + String? searchQuery; + final _musicScreenLogger = Logger("MusicScreen"); + + TabController? _tabController; + + final _audioServiceHelper = GetIt.instance(); + final _finampUserHelper = GetIt.instance(); + final _jellyfinApiHelper = GetIt.instance(); + + void _stopSearching() { + setState(() { + textEditingController.clear(); + searchQuery = null; + isSearching = false; + }); + } + + void _tabIndexCallback() { + var tabKey = FinampSettingsHelper.finampSettings.showTabs.entries + .where((element) => element.value) + .elementAt(_tabController!.index) + .key; + if (_tabController != null && + (tabKey == TabContentType.songs || + tabKey == TabContentType.artists || + tabKey == TabContentType.albums)) { + setState(() { + _showShuffleFab = true; + }); + } else { + if (_showShuffleFab) { + setState(() { + _showShuffleFab = false; + }); + } + } + } + + void _buildTabController() { + _tabController?.removeListener(_tabIndexCallback); + + _tabController = TabController( + length: FinampSettingsHelper.finampSettings.showTabs.entries + .where((element) => element.value) + .length, + vsync: this, + ); + + _tabController!.addListener(_tabIndexCallback); + } + + @override + void initState() { + super.initState(); + _buildTabController(); + } + + @override + void dispose() { + _tabController?.dispose(); + super.dispose(); + } + + FloatingActionButton? getFloatingActionButton() { + var tabList = FinampSettingsHelper.finampSettings.showTabs.entries + .where((element) => element.value) + .map((e) => e.key) + .toList(); + + // Show the floating action button only on the albums, artists and songs tab. + if (_tabController!.index == tabList.indexOf(TabContentType.songs)) { + return FloatingActionButton( + tooltip: AppLocalizations.of(context)!.shuffleAll, + onPressed: () async { + try { + await _audioServiceHelper + .shuffleAll(FinampSettingsHelper.finampSettings.isFavourite); + } catch (e) { + errorSnackbar(e, context); + } + }, + child: const Icon(Icons.shuffle), + ); + } else if (_tabController!.index == + tabList.indexOf(TabContentType.artists)) { + return FloatingActionButton( + tooltip: AppLocalizations.of(context)!.startMix, + onPressed: () async { + try { + if (_jellyfinApiHelper.selectedMixArtistsIds.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text( + AppLocalizations.of(context)!.startMixNoSongsArtist))); + } else { + await _audioServiceHelper.startInstantMixForArtists( + _jellyfinApiHelper.selectedMixArtistsIds); + } + } catch (e) { + errorSnackbar(e, context); + } + }, + child: const Icon(Icons.explore)); + } else if (_tabController!.index == + tabList.indexOf(TabContentType.albums)) { + return FloatingActionButton( + tooltip: AppLocalizations.of(context)!.startMix, + onPressed: () async { + try { + if (_jellyfinApiHelper.selectedMixAlbumIds.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text( + AppLocalizations.of(context)!.startMixNoSongsAlbum))); + } else { + await _audioServiceHelper.startInstantMixForAlbums( + _jellyfinApiHelper.selectedMixAlbumIds); + } + } catch (e) { + errorSnackbar(e, context); + } + }, + child: const Icon(Icons.explore)); + } else { + return null; + } + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: _finampUserHelper.finampUsersListenable, + builder: (context, value, _) { + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (context, value, _) { + final finampSettings = value.get("FinampSettings"); + + // Get the tabs from the user's tab order, and filter them to only + // include enabled tabs + final tabs = finampSettings!.tabOrder.where((e) => + FinampSettingsHelper.finampSettings.showTabs[e] ?? false); + + if (tabs.length != _tabController?.length) { + _musicScreenLogger.info( + "Rebuilding MusicScreen tab controller (${tabs.length} != ${_tabController?.length})"); + _buildTabController(); + } + + return WillPopScope( + onWillPop: () async { + if (isSearching) { + _stopSearching(); + return false; + } + return true; + }, + child: Scaffold( + appBar: AppBar( + title: isSearching + ? TextField( + controller: textEditingController, + autofocus: true, + onChanged: (value) => setState(() { + searchQuery = value; + }), + decoration: InputDecoration( + border: InputBorder.none, + hintText: MaterialLocalizations.of(context) + .searchFieldLabel, + ), + ) + : Text(_finampUserHelper.currentUser?.currentView?.name ?? + AppLocalizations.of(context)!.music), + bottom: TabBar( + controller: _tabController, + tabs: tabs + .map((tabType) => Tab( + text: tabType + .toLocalisedString(context) + .toUpperCase(), + )) + .toList(), + isScrollable: true, + tabAlignment: TabAlignment.start, + ), + leading: isSearching + ? BackButton( + onPressed: () => _stopSearching(), + ) + : null, + actions: isSearching + ? [ + IconButton( + icon: Icon( + Icons.cancel, + color: Theme.of(context).colorScheme.onSurface, + ), + onPressed: () => setState(() { + textEditingController.clear(); + searchQuery = null; + }), + tooltip: AppLocalizations.of(context)!.clear, + ) + ] + : [ + SortOrderButton( + tabs.elementAt(_tabController!.index), + ), + SortByMenuButton( + tabs.elementAt(_tabController!.index), + ), + IconButton( + icon: finampSettings.isFavourite + ? const Icon(Icons.favorite) + : const Icon(Icons.favorite_outline), + onPressed: finampSettings.isOffline + ? null + : () => FinampSettingsHelper.setIsFavourite( + !finampSettings.isFavourite), + tooltip: AppLocalizations.of(context)!.favourites, + ), + IconButton( + icon: const Icon(Icons.search), + onPressed: () => setState(() { + isSearching = true; + }), + tooltip: MaterialLocalizations.of(context) + .searchFieldLabel, + ), + ], + ), + bottomNavigationBar: const NowPlayingBar(), + drawer: const MusicScreenDrawer(), + floatingActionButton: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: getFloatingActionButton(), + ), + body: TabBarView( + controller: _tabController, + children: tabs + .map((tabType) => MusicScreenTabView( + tabContentType: tabType, + searchTerm: searchQuery, + isFavourite: finampSettings.isFavourite, + sortBy: finampSettings.getTabSortBy(tabType), + sortOrder: finampSettings.getSortOrder(tabType), + view: _finampUserHelper.currentUser?.currentView, + )) + .toList(), + ), + ), + ); + }, + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/screens/player_screen.dart b/lib/screens/player_screen.dart new file mode 100644 index 0000000..bfa0e5c --- /dev/null +++ b/lib/screens/player_screen.dart @@ -0,0 +1,226 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:get_it/get_it.dart'; +import 'package:octo_image/octo_image.dart'; +import 'package:simple_gesture_detector/simple_gesture_detector.dart'; + +import '../components/favourite_button.dart'; +import '../services/finamp_settings_helper.dart'; +import '../services/music_player_background_task.dart'; +import '../models/jellyfin_models.dart'; +import '../components/album_image.dart'; +import '../components/PlayerScreen/song_name.dart'; +import '../components/PlayerScreen/progress_slider.dart'; +import '../components/PlayerScreen/player_buttons.dart'; +import '../components/PlayerScreen/queue_button.dart'; +import '../components/PlayerScreen/playback_mode.dart'; +import '../components/PlayerScreen/add_to_playlist_button.dart'; +import '../components/PlayerScreen/sleep_timer_button.dart'; + +final _albumImageProvider = + StateProvider.autoDispose((_) => null); + +class PlayerScreen extends StatelessWidget { + const PlayerScreen({Key? key}) : super(key: key); + + static const routeName = "/nowplaying"; + + @override + Widget build(BuildContext context) { + final audioHandler = GetIt.instance(); + + return SimpleGestureDetector( + onVerticalSwipe: (direction) { + if (!FinampSettingsHelper.finampSettings.disableGesture && + direction == SwipeDirection.down) { + Navigator.of(context).pop(); + } + }, + onHorizontalSwipe: (direction) { + if (!FinampSettingsHelper.finampSettings.disableGesture) { + switch (direction) { + case SwipeDirection.left: + audioHandler.skipToNext(); + break; + case SwipeDirection.right: + audioHandler.skipToPrevious(); + break; + default: + break; + } + } + }, + child: Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + actions: const [ + SleepTimerButton(), + AddToPlaylistButton(), + ], + ), + // Required for sleep timer input + resizeToAvoidBottomInset: false, + extendBodyBehindAppBar: true, + body: Stack( + children: [ + if (FinampSettingsHelper.finampSettings.showCoverAsPlayerBackground) + const _BlurredPlayerScreenBackground(), + const SafeArea( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded( + child: _PlayerScreenAlbumImage(), + ), + Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SongName(), + ProgressSlider(), + PlayerButtons(), + Stack( + alignment: Alignment.center, + children: [ + Align( + alignment: Alignment.centerLeft, + child: PlaybackMode(), + ), + Align( + alignment: Alignment.center, + child: _PlayerScreenFavoriteButton(), + ), + Align( + alignment: Alignment.centerRight, + child: QueueButton(), + ) + ], + ) + ], + ), + ), + ) + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +/// This widget is just an AlbumImage in a StreamBuilder to get the song id. +class _PlayerScreenAlbumImage extends ConsumerWidget { + const _PlayerScreenAlbumImage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final audioHandler = GetIt.instance(); + + return StreamBuilder( + stream: audioHandler.mediaItem, + builder: (context, snapshot) { + final item = snapshot.data?.extras?["itemJson"] == null + ? null + : BaseItemDto.fromJson(snapshot.data!.extras!["itemJson"]); + + return item == null + ? AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: AlbumImage.borderRadius, + child: Container(color: Theme.of(context).cardColor), + ), + ) + : AlbumImage( + item: item, + imageProviderCallback: (imageProvider) => + // We need a post frame callback because otherwise this + // widget rebuilds on the same frame + WidgetsBinding.instance.addPostFrameCallback((_) => ref + .read(_albumImageProvider.notifier) + .state = imageProvider), + // Here we awkwardly get the next 3 queue items so that we + // can precache them (so that the image is already loaded + // when the next song comes on). + itemsToPrecache: audioHandler.queue.value + .sublist(min( + (audioHandler.playbackState.value.queueIndex ?? 0) + + 1, + audioHandler.queue.value.length)) + .take(3) + .map((e) => BaseItemDto.fromJson(e.extras!["itemJson"])) + .toList(), + ); + }); + } +} + +/// Same as [_PlayerScreenAlbumImage], but with a BlurHash instead. We also +/// filter the BlurHash so that it works as a background image. +class _BlurredPlayerScreenBackground extends ConsumerWidget { + const _BlurredPlayerScreenBackground({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final imageProvider = ref.watch(_albumImageProvider); + + return ClipRect( + child: imageProvider == null + ? const SizedBox.shrink() + : OctoImage( + image: imageProvider, + fit: BoxFit.cover, + placeholderBuilder: (_) => const SizedBox.shrink(), + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + imageBuilder: (context, child) => ColorFiltered( + colorFilter: ColorFilter.mode( + Theme.of(context).brightness == Brightness.dark + ? Colors.black.withOpacity(0.35) + : Colors.white.withOpacity(0.75), + BlendMode.srcOver), + child: ImageFiltered( + imageFilter: ImageFilter.blur( + sigmaX: 85, + sigmaY: 85, + tileMode: TileMode.mirror, + ), + child: SizedBox.expand(child: child), + ), + ), + ), + ); + } +} + +class _PlayerScreenFavoriteButton extends StatelessWidget { + const _PlayerScreenFavoriteButton({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final audioHandler = GetIt.instance(); + + return StreamBuilder( + stream: audioHandler.mediaItem, + builder: (context, snapshot) { + return FavoriteButton( + item: snapshot.data?.extras?["itemJson"] == null + ? null + : BaseItemDto.fromJson(snapshot.data!.extras!["itemJson"]), + inPlayer: true, + ); + }); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart new file mode 100644 index 0000000..5c63e69 --- /dev/null +++ b/lib/screens/settings_screen.dart @@ -0,0 +1,104 @@ +import 'package:finamp/screens/interaction_settings_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:locale_names/locale_names.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../services/finamp_settings_helper.dart'; +import '../services/locale_helper.dart'; +import 'transcoding_settings_screen.dart'; +import 'downloads_settings_screen.dart'; +import 'audio_service_settings_screen.dart'; +import 'layout_settings_screen.dart'; +import '../components/SettingsScreen/logout_list_tile.dart'; +import 'view_selector.dart'; +import 'language_selection_screen.dart'; + +class SettingsScreen extends StatelessWidget { + const SettingsScreen({Key? key}) : super(key: key); + + static const routeName = "/settings"; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.settings), + actions: [ + IconButton( + tooltip: AppLocalizations.of(context)!.about, + icon: const Icon(Icons.info), + onPressed: () async { + final applicationLegalese = + AppLocalizations.of(context)!.applicationLegalese; + PackageInfo packageInfo = await PackageInfo.fromPlatform(); + + showAboutDialog( + context: context, + applicationName: packageInfo.appName, + applicationVersion: packageInfo.version, + applicationLegalese: applicationLegalese, + ); + }, + ) + ], + ), + body: Scrollbar( + child: ListView( + children: [ + ListTile( + leading: const Icon(Icons.compress), + title: Text(AppLocalizations.of(context)!.transcoding), + onTap: () => Navigator.of(context) + .pushNamed(TranscodingSettingsScreen.routeName), + ), + ListTile( + leading: const Icon(Icons.folder), + title: Text(AppLocalizations.of(context)!.downloadLocations), + onTap: () => Navigator.of(context) + .pushNamed(DownloadsSettingsScreen.routeName), + ), + ListTile( + leading: const Icon(Icons.music_note), + title: Text(AppLocalizations.of(context)!.audioService), + onTap: () => Navigator.of(context) + .pushNamed(AudioServiceSettingsScreen.routeName), + ), + ListTile( + leading: const Icon(Icons.gesture), + title: Text(AppLocalizations.of(context)!.interactions), + onTap: () => Navigator.of(context) + .pushNamed(InteractionSettingsScreen.routeName), + ), + ListTile( + leading: const Icon(Icons.widgets), + title: Text(AppLocalizations.of(context)!.layoutAndTheme), + onTap: () => Navigator.of(context) + .pushNamed(LayoutSettingsScreen.routeName), + ), + ListTile( + leading: const Icon(Icons.library_music), + title: Text(AppLocalizations.of(context)!.selectMusicLibraries), + subtitle: FinampSettingsHelper.finampSettings.isOffline + ? Text( + AppLocalizations.of(context)!.notAvailableInOfflineMode) + : null, + enabled: !FinampSettingsHelper.finampSettings.isOffline, + onTap: () => + Navigator.of(context).pushNamed(ViewSelector.routeName), + ), + ListTile( + leading: const Icon(Icons.language), + title: Text(AppLocalizations.of(context)!.language), + subtitle: Text(LocaleHelper.locale?.nativeDisplayLanguage ?? + AppLocalizations.of(context)!.system), + onTap: () => Navigator.of(context) + .pushNamed(LanguageSelectionScreen.routeName), + ), + const LogoutListTile(), + ], + ), + ), + ); + } +} diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart new file mode 100644 index 0000000..2c77203 --- /dev/null +++ b/lib/screens/splash_screen.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; + +import '../services/finamp_user_helper.dart'; +import 'user_selector.dart'; +import 'music_screen.dart'; +import 'view_selector.dart'; + +class SplashScreen extends StatelessWidget { + const SplashScreen({Key? key}) : super(key: key); + + static const routeName = "/"; + + @override + Widget build(BuildContext context) { + final finampUserHelper = GetIt.instance(); + + if (finampUserHelper.currentUser == null) { + return const UserSelector(); + } else if (finampUserHelper.currentUser!.currentView == null) { + return const ViewSelector(); + } else { + return const MusicScreen(); + } + } +} diff --git a/lib/screens/tabs_settings_screen.dart b/lib/screens/tabs_settings_screen.dart new file mode 100644 index 0000000..0e47bb4 --- /dev/null +++ b/lib/screens/tabs_settings_screen.dart @@ -0,0 +1,70 @@ +import 'package:finamp/services/finamp_settings_helper.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../components/TabsSettingsScreen/hide_tab_toggle.dart'; + +class TabsSettingsScreen extends StatefulWidget { + const TabsSettingsScreen({Key? key}) : super(key: key); + + static const routeName = "/settings/tabs"; + + @override + State createState() => _TabsSettingsScreenState(); +} + +class _TabsSettingsScreenState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.tabs), + actions: [ + IconButton( + onPressed: () { + setState(() { + FinampSettingsHelper.resetTabs(); + }); + }, + icon: const Icon(Icons.refresh), + tooltip: AppLocalizations.of(context)!.resetTabs, + ) + ], + ), + body: Scrollbar( + child: ReorderableListView.builder( + // buildDefaultDragHandles: false, + itemCount: FinampSettingsHelper.finampSettings.tabOrder.length, + itemBuilder: (context, index) { + return HideTabToggle( + tabContentType: + FinampSettingsHelper.finampSettings.tabOrder[index], + key: + ValueKey(FinampSettingsHelper.finampSettings.tabOrder[index]), + ); + }, + onReorder: (oldIndex, newIndex) { + // It's a bit of a hack to call setState with no actual widget + // state, but it saves us from using listeners + setState(() { + // For some weird reason newIndex is one above what it should be + // when oldIndex is lower. This if statement is in Flutter's + // ReorderableListView documentation. + if (oldIndex < newIndex) { + newIndex -= 1; + } + + final oldValue = + FinampSettingsHelper.finampSettings.tabOrder[oldIndex]; + final newValue = + FinampSettingsHelper.finampSettings.tabOrder[newIndex]; + + FinampSettingsHelper.setTabOrder(oldIndex, newValue); + FinampSettingsHelper.setTabOrder(newIndex, oldValue); + }); + }, + ), + ), + ); + } +} diff --git a/lib/screens/transcoding_settings_screen.dart b/lib/screens/transcoding_settings_screen.dart new file mode 100644 index 0000000..d289299 --- /dev/null +++ b/lib/screens/transcoding_settings_screen.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../components/TranscodingSettingsScreen/transcode_switch.dart'; +import '../components/TranscodingSettingsScreen/bitrate_selector.dart'; + +class TranscodingSettingsScreen extends StatelessWidget { + const TranscodingSettingsScreen({Key? key}) : super(key: key); + + static const routeName = "/settings/transcoding"; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.transcoding), + ), + body: Scrollbar( + child: ListView( + children: [ + const TranscodeSwitch(), + const BitrateSelector(), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + AppLocalizations.of(context)!.jellyfinUsesAACForTranscoding, + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/user_selector.dart b/lib/screens/user_selector.dart new file mode 100644 index 0000000..d71127a --- /dev/null +++ b/lib/screens/user_selector.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +import '../components/UserSelector/private_user_sign_in.dart'; +import 'language_selection_screen.dart'; + +class UserSelector extends StatelessWidget { + const UserSelector({Key? key}) : super(key: key); + + static const routeName = "/login/userSelector"; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + actions: [ + IconButton( + onPressed: () => Navigator.of(context) + .pushNamed(LanguageSelectionScreen.routeName), + icon: const Icon(Icons.language), + ) + ], + ), + body: const Center(child: PrivateUserSignIn()), + ); + } +} diff --git a/lib/screens/view_selector.dart b/lib/screens/view_selector.dart new file mode 100644 index 0000000..1e55538 --- /dev/null +++ b/lib/screens/view_selector.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:get_it/get_it.dart'; + +import '../components/ViewSelector/no_music_libraries_message.dart'; +import '../services/finamp_user_helper.dart'; +import 'music_screen.dart'; +import '../services/jellyfin_api_helper.dart'; +import '../models/jellyfin_models.dart'; +import '../components/error_snackbar.dart'; + +class ViewSelector extends StatefulWidget { + const ViewSelector({Key? key}) : super(key: key); + + static const routeName = "/settings/views"; + + @override + State createState() => _ViewSelectorState(); +} + +class _ViewSelectorState extends State { + final _jellyfinApiHelper = GetIt.instance(); + final _finampUserHelper = GetIt.instance(); + late Future> viewListFuture; + final Map _views = {}; + bool isSubmitButtonEnabled = false; + + @override + void initState() { + super.initState(); + viewListFuture = _jellyfinApiHelper.getViews(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: viewListFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + // Finamp only supports music libraries. We used to allow people to + // select unsupported libraries, but some people selected "general" + // libraries and thought Finamp was broken. + if (snapshot.data!.isEmpty || + !snapshot.data! + .any((element) => element.collectionType == "music")) { + return NoMusicLibrariesMessage( + onRefresh: () { + setState(() { + _views.clear(); + viewListFuture = _jellyfinApiHelper.getViews(); + }); + }, + ); + } + + if (_views.isEmpty) { + _views.addEntries(snapshot.data! + .where((element) => element.collectionType != "playlists") + .map((e) => MapEntry(e, e.collectionType == "music"))); + + // If only one music library is available and user doesn't have a + // view saved (assuming setup is in progress), skip the selector. + if (_views.values.where((element) => element == true).length == 1 && + _finampUserHelper.currentUser!.currentView == null) { + _submitChoice(); + } else { + if (mounted) { + isSubmitButtonEnabled = _views.values.contains(true); + } + } + } + + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.selectMusicLibraries), + ), + floatingActionButton: isSubmitButtonEnabled + ? FloatingActionButton( + onPressed: _submitChoice, + child: const Icon(Icons.check), + ) + : null, + body: Scrollbar( + child: ListView.builder( + itemCount: _views.length, + itemBuilder: (context, index) { + final isSelected = _views.values.elementAt(index); + final view = _views.keys.elementAt(index); + + return CheckboxListTile( + value: isSelected, + enabled: view.collectionType == "music", + title: Text(_views.keys.elementAt(index).name ?? + AppLocalizations.of(context)!.unknownName), + onChanged: (value) { + setState(() { + _views[_views.keys.elementAt(index)] = value!; + isSubmitButtonEnabled = _views.values.contains(true); + }); + }, + ); + }, + ), + ), + ); + } else if (snapshot.hasError) { + errorSnackbar(snapshot.error, context); + // TODO: Let the user refresh the page + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error), + Text(snapshot.error.toString()), + ], + )); + } else { + return const Center(child: CircularProgressIndicator.adaptive()); + } + }, + ); + } + + void _submitChoice() { + if (_views.values.where((element) => element == true).isEmpty) { + // This should no longer be possible since the submit button only shows + // when views are selected, but we return just in case + return; + } else { + try { + _finampUserHelper.setCurrentUserViews(_views.entries + .where((element) => element.value == true) + .map((e) => e.key) + .toList()); + // allow navigation to music screen while selector is being built + Future.microtask(() => Navigator.of(context) + .pushNamedAndRemoveUntil(MusicScreen.routeName, (route) => false)); + } catch (e) { + errorSnackbar(e, context); + } + } + } +} diff --git a/lib/services/album_image_provider.dart b/lib/services/album_image_provider.dart new file mode 100644 index 0000000..2a5a464 --- /dev/null +++ b/lib/services/album_image_provider.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; + +import '../models/jellyfin_models.dart'; +import 'downloads_helper.dart'; +import 'finamp_settings_helper.dart'; +import 'jellyfin_api_helper.dart'; + +/// A class that handles returning ImageProviders for Jellyfin items. This class +/// only has one static function to handle this, and has no constructors. It's a +/// bit of a jank way to do this, so if you know a better way, please let me +/// know :) +class AlbumImageProvider { + static Future init( + BaseItemDto item, { + int? maxWidth, + int? maxHeight, + List? itemsToPrecache, + BuildContext? context, + }) async { + assert(itemsToPrecache == null ? true : context != null); + if (item.imageId == null) { + return null; + } + + if (itemsToPrecache != null) { + for (final itemToPrecache in itemsToPrecache) { + init(itemToPrecache, maxWidth: maxWidth, maxHeight: maxHeight) + .then((value) { + if (value != null) { + precacheImage(value, context!); + } + }); + } + } + + final jellyfinApiHelper = GetIt.instance(); + final downloadsHelper = GetIt.instance(); + + final downloadedImage = downloadsHelper.getDownloadedImage(item); + + if (downloadedImage == null) { + if (FinampSettingsHelper.finampSettings.isOffline) { + return null; + } + + Uri? imageUrl = jellyfinApiHelper.getImageUrl( + item: item, + maxWidth: maxWidth, + maxHeight: maxHeight, + ); + + if (imageUrl == null) { + return null; + } + + return NetworkImage(imageUrl.toString()); + } + + if (await downloadsHelper.verifyDownloadedImage(downloadedImage)) { + return FileImage(downloadedImage.file); + } + + // If we've got this far, the download image has failed to verify. + // We recurse, which will either return a NetworkImage or an error depending + // on if the app is offline. + return init(item, maxWidth: maxWidth, maxHeight: maxHeight); + } +} diff --git a/lib/services/audio_service_helper.dart b/lib/services/audio_service_helper.dart new file mode 100644 index 0000000..925bb21 --- /dev/null +++ b/lib/services/audio_service_helper.dart @@ -0,0 +1,222 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logging/logging.dart'; +import 'package:uuid/uuid.dart'; + +import 'finamp_user_helper.dart'; +import 'jellyfin_api_helper.dart'; +import 'finamp_settings_helper.dart'; +import 'downloads_helper.dart'; +import '../models/jellyfin_models.dart'; +import 'music_player_background_task.dart'; + +/// Just some functions to make talking to AudioService a bit neater. +class AudioServiceHelper { + final _jellyfinApiHelper = GetIt.instance(); + final _downloadsHelper = GetIt.instance(); + final _audioHandler = GetIt.instance(); + final _finampUserHelper = GetIt.instance(); + final audioServiceHelperLogger = Logger("AudioServiceHelper"); + + /// Replaces the queue with the given list of items. If startAtIndex is specified, Any items below it + /// will be ignored. This is used for when the user taps in the middle of an album to start from that point. + Future replaceQueueWithItem({ + required List itemList, + int initialIndex = 0, + bool shuffle = false, + }) async { + try { + if (initialIndex > itemList.length) { + return Future.error( + "startAtIndex is bigger than the itemList! ($initialIndex > ${itemList.length})"); + } + + List queue = []; + for (BaseItemDto item in itemList) { + try { + queue.add(await _generateMediaItem(item)); + } catch (e) { + audioServiceHelperLogger.severe(e); + } + } + + // if (!shuffle) { + // // Give the audio service our next initial index so that playback starts + // // at that index. We don't do this if shuffling because it causes the + // // queue to always start at the start (although you could argue that we + // // still should if initialIndex is not 0, but that doesn't happen + // // anywhere in this app so oh well). + _audioHandler.setNextInitialIndex(initialIndex); + // } + + await _audioHandler.updateQueue(queue); + + if (shuffle) { + await _audioHandler.setShuffleMode(AudioServiceShuffleMode.all); + } else { + await _audioHandler.setShuffleMode(AudioServiceShuffleMode.none); + } + + _audioHandler.play(); + } catch (e) { + audioServiceHelperLogger.severe(e); + return Future.error(e); + } + } + + bool hasQueueItems() { + return (_audioHandler.queue.valueOrNull?.length ?? 0) != 0; + } + + @Deprecated("Use addQueueItems instead") + Future addQueueItem(BaseItemDto item) async { + await addQueueItems([item]); + } + + Future addQueueItems(List items) async { + try { + // If the queue is empty (like when the app is first launched), run the + // replace queue function instead so that the song gets played + if ((_audioHandler.queue.valueOrNull?.length ?? 0) == 0) { + await replaceQueueWithItem(itemList: items); + return; + } + + final mediaItems = + await Future.wait(items.map((i) => _generateMediaItem(i))); + await _audioHandler.addQueueItems(mediaItems); + } catch (e) { + audioServiceHelperLogger.severe(e); + return Future.error(e); + } + } + + Future insertQueueItemsNext(List items) async { + try { + // See above comment in addQueueItem + if ((_audioHandler.queue.valueOrNull?.length ?? 0) == 0) { + await replaceQueueWithItem(itemList: items); + return; + } + + final mediaItems = + await Future.wait(items.map((i) => _generateMediaItem(i))); + await _audioHandler.insertQueueItemsNext(mediaItems); + } catch (e) { + audioServiceHelperLogger.severe(e); + return Future.error(e); + } + } + + /// Shuffles every song in the user's current view. + Future shuffleAll(bool isFavourite) async { + List? items; + + if (FinampSettingsHelper.finampSettings.isOffline) { + // If offline, get a shuffled list of songs from _downloadsHelper. + // This is a bit inefficient since we have to get all of the songs and + // shuffle them before making a sublist, but I couldn't think of a better + // way. + items = _downloadsHelper.downloadedItems.map((e) => e.song).toList(); + items.shuffle(); + if (items.length - 1 > + FinampSettingsHelper.finampSettings.songShuffleItemCount) { + items = items.sublist( + 0, FinampSettingsHelper.finampSettings.songShuffleItemCount); + } + } else { + // If online, get all audio items from the user's view + items = await _jellyfinApiHelper.getItems( + isGenres: false, + parentItem: _finampUserHelper.currentUser!.currentView, + includeItemTypes: "Audio", + filters: isFavourite ? "IsFavorite" : null, + limit: FinampSettingsHelper.finampSettings.songShuffleItemCount, + sortBy: "Random", + ); + } + + if (items != null) { + await replaceQueueWithItem(itemList: items, shuffle: true); + } + } + + /// Start instant mix from item. + Future startInstantMixForItem(BaseItemDto item) async { + List? items; + + try { + items = await _jellyfinApiHelper.getInstantMix(item); + if (items != null) { + await replaceQueueWithItem(itemList: items, shuffle: false); + } + } catch (e) { + audioServiceHelperLogger.severe(e); + return Future.error(e); + } + } + + /// Start instant mix from a selection of artists. + Future startInstantMixForArtists(List artistIds) async { + List? items; + + try { + items = await _jellyfinApiHelper.getArtistMix(artistIds); + if (items != null) { + await replaceQueueWithItem(itemList: items, shuffle: false); + } + } catch (e) { + audioServiceHelperLogger.severe(e); + return Future.error(e); + } + } + + /// Start instant mix from a selection of albums. + Future startInstantMixForAlbums(List albumIds) async { + List? items; + + try { + items = await _jellyfinApiHelper.getAlbumMix(albumIds); + if (items != null) { + await replaceQueueWithItem(itemList: items, shuffle: false); + } + } catch (e) { + audioServiceHelperLogger.severe(e); + return Future.error(e); + } + } + + Future _generateMediaItem(BaseItemDto item) async { + const uuid = Uuid(); + + final downloadedSong = _downloadsHelper.getDownloadedSong(item.id); + final isDownloaded = downloadedSong == null + ? false + : await _downloadsHelper.verifyDownloadedSong(downloadedSong); + + return MediaItem( + id: uuid.v4(), + album: item.album ?? "Unknown Album", + artist: item.artists?.join(", ") ?? item.albumArtist, + artUri: _downloadsHelper.getDownloadedImage(item)?.file.uri ?? + _jellyfinApiHelper.getImageUrl(item: item), + title: item.name ?? "Unknown Name", + extras: { + // "parentId": item.parentId, + // "itemId": item.id, + "itemJson": item.toJson(), + "shouldTranscode": FinampSettingsHelper.finampSettings.shouldTranscode, + "downloadedSongJson": isDownloaded + ? (_downloadsHelper.getDownloadedSong(item.id))!.toJson() + : null, + "isOffline": FinampSettingsHelper.finampSettings.isOffline, + // TODO: Maybe add transcoding bitrate here? + }, + // Jellyfin returns microseconds * 10 for some reason + duration: Duration( + microseconds: + (item.runTimeTicks == null ? 0 : item.runTimeTicks! ~/ 10), + ), + ); + } +} diff --git a/lib/services/case_insensitive_pattern.dart b/lib/services/case_insensitive_pattern.dart new file mode 100644 index 0000000..ae58093 --- /dev/null +++ b/lib/services/case_insensitive_pattern.dart @@ -0,0 +1,19 @@ +/// A pattern for case-insensitive matching. Used when sanitising logs as +/// Chopper logs the base URL in lowercase. +class CaseInsensitivePattern implements Pattern { + late String matcher; + + CaseInsensitivePattern(String matcher) { + this.matcher = matcher.toLowerCase(); + } + + @override + Iterable allMatches(String string, [int start = 0]) { + return matcher.allMatches(string.toLowerCase(), start); + } + + @override + Match? matchAsPrefix(String string, [int start = 0]) { + return matcher.matchAsPrefix(string.toLowerCase(), start); + } +} diff --git a/lib/services/censored_log.dart b/lib/services/censored_log.dart new file mode 100644 index 0000000..16d106f --- /dev/null +++ b/lib/services/censored_log.dart @@ -0,0 +1,33 @@ +import 'package:finamp/services/contains_login.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logging/logging.dart'; + +import 'case_insensitive_pattern.dart'; +import 'finamp_user_helper.dart'; + +extension CensoredMessage on LogRecord { + /// The uncensored log string to be shown to the user + String get logString => + "[$loggerName/${level.name}] $time: $message\n\n${stackTrace.toString()}"; + + String get loginCensoredMessage => containsLogin ? "LOGIN BODY" : message; + + String get censoredMessage { + if (containsLogin) { + return loginCensoredMessage; + } + + final finampUserHelper = GetIt.instance(); + + String workingLogString = logString; + + for (final user in finampUserHelper.finampUsers) { + workingLogString = workingLogString.replaceAll( + CaseInsensitivePattern(user.baseUrl), "BASEURL"); + workingLogString = workingLogString.replaceAll( + CaseInsensitivePattern(user.accessToken), "TOKEN"); + } + + return workingLogString; + } +} diff --git a/lib/services/chopper_aggregate_logger.dart b/lib/services/chopper_aggregate_logger.dart new file mode 100644 index 0000000..54467f4 --- /dev/null +++ b/lib/services/chopper_aggregate_logger.dart @@ -0,0 +1,118 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:chopper/chopper.dart' as chopper; +import 'package:chopper/src/chopper_log_record.dart'; +import 'package:logging/logging.dart'; + +/// A logger that aggregates the request and response logs from Chopper. +/// Once a request (or response) is completed, +/// the whole request log is flushed to the delegated chopperLogger. +/// +/// All other operations are delegated by default. +class ChopperAggregateLogger implements Logger { + final Logger _delegate = chopper.chopperLogger; + + final Map _requests = HashMap(); + + final Map _responses = HashMap(); + + @override + String get name => _delegate.name; + + @override + String get fullName => _delegate.fullName; + + @override + Logger? get parent => _delegate.parent; + + @override + Level get level => _delegate.level; + + @override + set level(Level? value) { + _delegate.level = value; + } + + @override + Map get children => _delegate.children; + + @override + void log(Level logLevel, Object? message, + [Object? error, StackTrace? stackTrace, Zone? zone]) { + if (message is ChopperLogRecord) { + if (message.request != null) { + _requests[message.request]?.writeln(message.message); + return; + } else if (message.response != null) { + _responses[message.response]?.writeln(message.message); + return; + } + } + _delegate.log(logLevel, message, error, stackTrace, zone); + } + + void onStartRequest(chopper.Request request) { + _requests[request] = StringBuffer(); + } + + void onEndRequest(chopper.Request request) { + info(_requests.remove(request)?.toString().trim()); + } + + void onStartResponse(chopper.Response response) { + _responses[response] = StringBuffer(); + } + + void onEndResponse(chopper.Response response) { + info(_responses.remove(response)?.toString().trim()); + } + + @override + Stream get onLevelChanged => _delegate.onLevelChanged; + + @override + Stream get onRecord => _delegate.onRecord; + + @override + void clearListeners() { + _delegate.clearListeners(); + } + + @override + bool isLoggable(Level value) { + return _delegate.isLoggable(value); + } + + @override + void finest(Object? message, [Object? error, StackTrace? stackTrace]) => + log(Level.FINEST, message, error, stackTrace); + + @override + void finer(Object? message, [Object? error, StackTrace? stackTrace]) => + log(Level.FINER, message, error, stackTrace); + + @override + void fine(Object? message, [Object? error, StackTrace? stackTrace]) => + log(Level.FINE, message, error, stackTrace); + + @override + void config(Object? message, [Object? error, StackTrace? stackTrace]) => + log(Level.CONFIG, message, error, stackTrace); + + @override + void info(Object? message, [Object? error, StackTrace? stackTrace]) => + log(Level.INFO, message, error, stackTrace); + + @override + void warning(Object? message, [Object? error, StackTrace? stackTrace]) => + log(Level.WARNING, message, error, stackTrace); + + @override + void severe(Object? message, [Object? error, StackTrace? stackTrace]) => + log(Level.SEVERE, message, error, stackTrace); + + @override + void shout(Object? message, [Object? error, StackTrace? stackTrace]) => + log(Level.SHOUT, message, error, stackTrace); +} diff --git a/lib/services/contains_login.dart b/lib/services/contains_login.dart new file mode 100644 index 0000000..e01a18c --- /dev/null +++ b/lib/services/contains_login.dart @@ -0,0 +1,6 @@ +import 'package:logging/logging.dart'; + +extension ContainsLogin on LogRecord { + /// Whether or not the log record contains the user's login details. + bool get containsLogin => message.contains('{"Username'); +} diff --git a/lib/services/download_update_stream.dart b/lib/services/download_update_stream.dart new file mode 100644 index 0000000..d7d1a2c --- /dev/null +++ b/lib/services/download_update_stream.dart @@ -0,0 +1,47 @@ +import 'dart:async'; +import 'dart:isolate'; +import 'dart:ui'; + +import 'package:flutter_downloader/flutter_downloader.dart'; + +class DownloadUpdate { + DownloadUpdate({ + required this.id, + required this.status, + required this.progress, + }); + + final String id; + final DownloadTaskStatus status; + final int progress; +} + +/// This stream is used to provide download updates in the UI. A single callback +/// in main.dart adds all of flutter_downloader's events to this stream so that +/// changes can be easily listened to in widgets. +class DownloadUpdateStream { + final ReceivePort _port = ReceivePort(); + // ignore: close_sinks + final _controller = StreamController.broadcast(); + + Stream get stream => _controller.stream; + + void setupSendPort() { + IsolateNameServer.registerPortWithName( + _port.sendPort, 'downloader_send_port'); + _port.listen((dynamic data) { + String id = data[0]; + DownloadTaskStatus status = DownloadTaskStatus(data[1]); + int progress = data[2]; + + add(DownloadUpdate( + id: id, + status: status, + progress: progress, + )); + }); + } + + /// Add a new download update to the download update stream. + void add(DownloadUpdate downloadUpdate) => _controller.add(downloadUpdate); +} diff --git a/lib/services/downloads_helper.dart b/lib/services/downloads_helper.dart new file mode 100644 index 0000000..f7c176a --- /dev/null +++ b/lib/services/downloads_helper.dart @@ -0,0 +1,1180 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_downloader/flutter_downloader.dart'; +import 'package:get_it/get_it.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path_helper; + +import 'finamp_settings_helper.dart'; +import 'finamp_user_helper.dart'; +import 'jellyfin_api.dart'; +import 'jellyfin_api_helper.dart'; +import 'get_internal_song_dir.dart'; +import '../models/jellyfin_models.dart'; +import '../models/finamp_models.dart'; + +class DownloadsHelper { + List queue = []; + final _jellyfinApiData = GetIt.instance(); + final _finampUserHelper = GetIt.instance(); + final _downloadedItemsBox = Hive.box("DownloadedItems"); + final _downloadedParentsBox = Hive.box("DownloadedParents"); + final _downloadIdsBox = Hive.box("DownloadIds"); + final _downloadedImagesBox = Hive.box("DownloadedImages"); + final _downloadedImageIdsBox = Hive.box("DownloadedImageIds"); + + final _downloadsLogger = Logger("DownloadsHelper"); + + List? _downloadedParentsCache; + + Iterable get downloadedParents => + _downloadedParentsCache ?? _loadSortedDownloadedParents(); + + DownloadsHelper() { + _downloadedParentsBox.watch().listen((event) { + _downloadedParentsCache = null; + }); + } + + List _loadSortedDownloadedParents() { + return _downloadedParentsCache = _downloadedParentsBox.values.toList() + ..sort((a, b) { + final nameA = a.item.name; + final nameB = b.item.name; + + return nameA != null && nameB != null + ? nameA.toLowerCase().compareTo(nameB.toLowerCase()) + : 0; + }); + } + + Future addDownloads({ + required List items, + required BaseItemDto parent, + required bool useHumanReadableNames, + required DownloadLocation downloadLocation, + + /// The view that this download is in. Used for sorting in offline mode. + required String viewId, + }) async { + // Check if we have external storage permission. It's a bit of a hack, but + // we only do this if downloadLocation.deletable is true because if it's + // true, we're downloading to a user location. You wouldn't want the app + // asking for permission when using internal storage. + if (downloadLocation.deletable) { + if (!await Permission.storage.request().isGranted) { + _downloadsLogger.severe("Storage permission is not granted, exiting"); + return Future.error( + "Storage permission is required for external storage"); + } + } + + try { + if (!_downloadedParentsBox.containsKey(parent.id)) { + // If the current album doesn't exist, add the album to the box of albums + _downloadsLogger.info( + "Album ${parent.name} (${parent.id}) not in albums box, adding now."); + _downloadedParentsBox.put( + parent.id, + DownloadedParent( + item: parent, downloadedChildren: {}, viewId: viewId)); + } + + if (parent.blurHash != null && + !_downloadedImagesBox.containsKey(parent.blurHash) && + parent.hasOwnImage) { + _downloadsLogger + .info("Downloading parent image for ${parent.name} (${parent.id}"); + + final downloadDir = await _getDownloadDirectory( + item: parent, + downloadBaseDir: Directory(downloadLocation.path), + useHumanReadableNames: useHumanReadableNames, + ); + + await _downloadImage( + item: parent, + downloadDir: downloadDir, + downloadLocation: downloadLocation, + ); + } + + for (final item in items) { + _downloadsLogger.info("Attempting to download ${item.id}"); + if (_downloadedItemsBox.containsKey(item.id)) { + // If the item already exists, add the parent item to its requiredBy field and skip actually downloading the song. + // We also add the item to the downloadedChildren of the parent that we're downloading. + _downloadsLogger.info( + "Item ${item.id} already exists in downloadedItemsBox, adding requiredBy to DownloadedItem and adding to ${parent.id}'s downloadedChildren"); + + // This is technically nullable but we check if it contains the key + // in order to get to this point. + DownloadedSong itemFromBox = _downloadedItemsBox.get(item.id)!; + + itemFromBox.requiredBy.add(parent.id); + addDownloadedSong(itemFromBox); + _addItemToDownloadedAlbum(parent.id, item); + continue; + } + + // Base URL shouldn't be null at this point (user has to be logged in + // to get to the point where they can add downloads). + String songUrl = + "${_finampUserHelper.currentUser!.baseUrl}/Items/${item.id}/File"; + + List? mediaSourceInfo = + await _jellyfinApiData.getPlaybackInfo(item.id); + + String fileName; + Directory downloadDir = await _getDownloadDirectory( + item: item, + downloadBaseDir: Directory(downloadLocation.path), + useHumanReadableNames: useHumanReadableNames, + ); + if (useHumanReadableNames) { + if (mediaSourceInfo == null) { + _downloadsLogger.warning( + "Media source info for ${item.id} returned null, filename may be weird."); + } + // We use a regex to filter out bad characters from song/album names. + fileName = + "${item.album?.replaceAll(RegExp('[/?<>\\:*|"]'), "_")} - ${item.indexNumber ?? 0} - ${item.name?.replaceAll(RegExp('[/?<>\\:*|"]'), "_")}.${mediaSourceInfo?[0].container}"; + } else { + fileName = "${item.id}.${mediaSourceInfo?[0].container}"; + downloadDir = Directory(downloadLocation.path); + } + + String authHeader = await getAuthHeader(); + + String? songDownloadId = await FlutterDownloader.enqueue( + url: songUrl, + savedDir: downloadDir.path, + headers: { + "Authorization": authHeader, + }, + fileName: fileName, + openFileFromNotification: false, + showNotification: false, + ); + + if (songDownloadId == null) { + _downloadsLogger.severe( + "Adding download for ${item.id} failed! downloadId is null. This only really happens if something goes horribly wrong with flutter_downloader's platform interface. This should never happen..."); + } + DownloadedSong songInfo = DownloadedSong( + song: item, + mediaSourceInfo: mediaSourceInfo![0], + downloadId: songDownloadId!, + requiredBy: [parent.id], + path: path_helper.relative( + path_helper.join(downloadDir.path, fileName), + from: downloadLocation.path), + useHumanReadableNames: useHumanReadableNames, + viewId: viewId, + downloadLocationId: downloadLocation.id, + ); + + // Adds the current song to the downloaded items box with its media info and download id + addDownloadedSong(songInfo); + + // Adds the current song to the parent's DownloadedAlbum + _addItemToDownloadedAlbum(parent.id, item); + + // Adds the download id and the item id to the download ids box so that we can track the download id back to the actual song + + _downloadIdsBox.put(songDownloadId, songInfo); + + // If the item has an blurhash, handle getting/noting the downloaded + // image. + if (item.blurHash != null) { + if (_downloadedImagesBox.containsKey(item.blurHash)) { + _downloadsLogger.info( + "Image ${item.blurHash} already exists in downloadedImagesBox, adding requiredBy to DownloadedImage."); + + final downloadedImage = _downloadedImagesBox.get(item.blurHash)!; + + downloadedImage.requiredBy.add(item.id); + + _addDownloadImageToDownloadedImages(downloadedImage); + } else if (item.hasOwnImage) { + _downloadsLogger.info( + "Downloading image for ${item.name} (${item.id}) as it has its own image"); + await _downloadImage( + item: item, + downloadDir: downloadDir, + downloadLocation: downloadLocation, + ); + } else if (parent.type != "MusicAlbum") { + _downloadsLogger.info( + "Downloading parent image for ${item.name} (${item.id}) as the parent is not an album but the parent image is not downloaded"); + await _downloadImage( + item: item, + downloadDir: downloadDir, + downloadLocation: downloadLocation, + ); + } + } + } + } catch (e) { + _downloadsLogger.severe(e); + return Future.error(e); + } + } + + /// Gets the download status for the given item ids (Jellyfin item id, not flutter_downloader task id). + Future?> getDownloadStatus(List itemIds) async { + try { + List downloadIds = []; + + for (final itemId in itemIds) { + if (_downloadedItemsBox.containsKey(itemId)) { + // _downloadedItemsBox.get shouldn't return null as an item with the + // key itemId already exists. + downloadIds.add(_downloadedItemsBox.get(itemId)!.downloadId); + } + } + List? downloadStatuses = + await FlutterDownloader.loadTasksWithRawQuery( + query: + "SELECT * FROM task WHERE task_id IN ${_dartListToSqlList(downloadIds)}"); + return downloadStatuses; + } catch (e) { + _downloadsLogger.severe(e); + return Future.error(e); + } + } + + Future removeChildFromParent({required String parentId, required List childIds}) async { + var album = _downloadedParentsBox.get(parentId); + for (String childId in childIds) { + album?.downloadedChildren.removeWhere((key, value) => key == childId); + } + } + + Future deleteSong({required String jellyfinItemId}) async { + final List deleteDownloadFutures = []; + final Map directoriesToCheck = {}; + + _downloadsLogger.info("Attempting to delete $jellyfinItemId"); + DownloadedSong? downloadedSong = getDownloadedSong(jellyfinItemId); + + if (downloadedSong == null) { + _downloadsLogger.info( + "Could not find $jellyfinItemId in downloadedItemsBox, assuming already deleted"); + } else { + DownloadedImage? downloadedImage = getDownloadedImage(downloadedSong.song); + downloadedImage?.requiredBy.remove(jellyfinItemId); + if (downloadedSong.requiredBy.isEmpty) { + _downloadsLogger.info( + "Item $jellyfinItemId has no dependencies, deleting files"); + + _downloadsLogger.info( + "Deleting ${downloadedSong.downloadId} from flutter_downloader"); + deleteDownloadFutures.add(FlutterDownloader.remove( + taskId: downloadedSong.downloadId, + shouldDeleteContent: true, + )); + + await _downloadedItemsBox.delete(jellyfinItemId); + await _downloadIdsBox.delete(downloadedSong.downloadId); + + // We only have to care about deleting directories if files are + // stored with human readable file names. + if (downloadedSong.useHumanReadableNames) { + // We use the parent here since downloadedSong.path still includes + // the filename. We assume that downloadedSong.path is not null, + // as if downloadedSong.useHumanReadableNames is true, the path + // would have been set at some point. + Directory songDirectory = downloadedSong.file.parent; + + if (!directoriesToCheck.containsKey(songDirectory.path)) { + // Add the directory to the directory map. + // We keep the directories in a map so that we can easily check + // for duplicates. + directoriesToCheck[songDirectory.path] = songDirectory; + } + } + + if (downloadedImage?.requiredBy.isEmpty ?? false) { + deleteDownloadFutures.add(_handleDeleteImage(downloadedImage!)); + } + } + } + } + + /// This function will delete all given jellyfinItemIds regardless if they are still used + /// by other albums/playlists + Future deleteDownloadChildren({required List jellyfinItemIds, String? deletedFor}) async { + final List deleteDownloadFutures = []; + final Map directoriesToCheck = {}; + + for (final jellyfinItemId in jellyfinItemIds) { + _downloadsLogger.info("Attempting to delete $jellyfinItemId"); + DownloadedSong? downloadedSong = getDownloadedSong(jellyfinItemId); + + if (downloadedSong == null) { + _downloadsLogger.info( + "Could not find $jellyfinItemId in downloadedItemsBox, assuming already deleted"); + } else { + DownloadedImage? downloadedImage = + getDownloadedImage(downloadedSong.song); + + if (deletedFor != null) { + _downloadsLogger + .info("Removing $deletedFor dependency from $jellyfinItemId, current dependencies ${downloadedSong.requiredBy}"); + downloadedSong.requiredBy.removeWhere((item) => item == deletedFor); + } + + downloadedImage?.requiredBy.remove(jellyfinItemId); + + if (downloadedSong.requiredBy.isEmpty || deletedFor == null) { + _downloadsLogger.info( + "Item $jellyfinItemId has no dependencies or was manually deleted, deleting files"); + + _downloadsLogger.info( + "Deleting ${downloadedSong.downloadId} from flutter_downloader"); + deleteDownloadFutures.add(FlutterDownloader.remove( + taskId: downloadedSong.downloadId, + shouldDeleteContent: true, + )); + + await _downloadedItemsBox.delete(jellyfinItemId); + await _downloadIdsBox.delete(downloadedSong.downloadId); + + if (deletedFor != null) { + DownloadedParent? downloadedAlbumTemp = + _downloadedParentsBox.get(deletedFor); + if (downloadedAlbumTemp != null) { + downloadedAlbumTemp.downloadedChildren.remove(jellyfinItemId); + await _downloadedParentsBox.put(deletedFor, downloadedAlbumTemp); + } + + downloadedImage?.requiredBy.remove(deletedFor); + } + + // We only have to care about deleting directories if files are + // stored with human readable file names. + if (downloadedSong.useHumanReadableNames) { + // We use the parent here since downloadedSong.path still includes + // the filename. We assume that downloadedSong.path is not null, + // as if downloadedSong.useHumanReadableNames is true, the path + // would have been set at some point. + Directory songDirectory = downloadedSong.file.parent; + + if (!directoriesToCheck.containsKey(songDirectory.path)) { + // Add the directory to the directory map. + // We keep the directories in a map so that we can easily check + // for duplicates. + directoriesToCheck[songDirectory.path] = songDirectory; + } + } + } + + if (downloadedImage?.requiredBy.isEmpty ?? false) { + deleteDownloadFutures.add(_handleDeleteImage(downloadedImage!)); + } + } + } + + await Future.wait(deleteDownloadFutures); + + for (var element in directoriesToCheck.values) { + // Loop through each directory and check if it's empty. If it is, delete the directory. + if (await element.list().isEmpty) { + _downloadsLogger.info("${element.path} is empty, deleting"); + element.delete(); + } + } + } + + Future deleteDownloadParent({required String deletedFor}) async { + final parentItem = getDownloadedParent(deletedFor)?.item; + + if (parentItem != null) { + final downloadedImage = getDownloadedImage(parentItem); + + downloadedImage?.requiredBy.remove(deletedFor); + + if (downloadedImage != null) { + await _handleDeleteImage(downloadedImage); + } + } + + _downloadedParentsBox.delete(deletedFor); + } + + /// Deletes download tasks for items with ids in jellyfinItemIds from storage + /// and removes the Hive entries for that download task. If deletedFor is + /// specified, also do checks to delete the parent album. The only time + /// deletedFor is not specified is when a user plays a song that has been + /// manually deleted. + Future deleteParentAndChildDownloads({ + required List jellyfinItemIds, + String? deletedFor, + }) async { + try { + await deleteDownloadChildren(jellyfinItemIds: jellyfinItemIds, deletedFor: deletedFor); + if (deletedFor != null) { + await deleteDownloadParent(deletedFor: deletedFor); + } + } catch (e) { + _downloadsLogger.severe(e); + return Future.error(e); + } + } + + /// Deletes an image if it no longer has any dependents (requiredBy is empty) + Future _handleDeleteImage(DownloadedImage downloadedImage) async { + if (downloadedImage.requiredBy.isEmpty) { + _downloadsLogger + .info("Image ${downloadedImage.id} has no dependencies, deleting."); + + await _deleteImage(downloadedImage); + } + } + + /// Deletes an image, without checking if anything else depends on it first. + Future _deleteImage(DownloadedImage downloadedImage) async { + _downloadsLogger + .info("Deleting ${downloadedImage.downloadId} from flutter_downloader"); + + _downloadedImagesBox.delete(downloadedImage.id); + _downloadedImageIdsBox.delete(downloadedImage.downloadId); + + await FlutterDownloader.remove( + taskId: downloadedImage.downloadId, + shouldDeleteContent: true, + ); + } + + /// Calculates the total file size of the given directory. + /// Returns the total file size in bytes. + /// Returns 0 if the directory doesn't exist. + Future getDirSize(Directory directory) async { + try { + // https://stackoverflow.com/questions/57140112/how-to-get-the-size-of-a-directory-including-its-files + if (await directory.exists()) { + int totalSize = 0; + try { + await for (FileSystemEntity entity in directory.list()) { + if (entity is File) { + totalSize += await entity.length(); + } + } + } catch (e) { + return Future.error(e); + } + return totalSize; + } else { + return 0; + } + } catch (e) { + _downloadsLogger.severe(e); + return Future.error(e); + } + } + + Future?> getIncompleteDownloads() async { + try { + return await FlutterDownloader.loadTasksWithRawQuery( + query: "SELECT * FROM task WHERE status <> 3"); + } catch (e) { + _downloadsLogger.severe(e); + return Future.error(e); + } + } + + Future?> getDownloadsWithStatus( + DownloadTaskStatus downloadTaskStatus) async { + try { + return await FlutterDownloader.loadTasksWithRawQuery( + query: + "SELECT * FROM task WHERE status = ${downloadTaskStatus.value}"); + } catch (e) { + _downloadsLogger.severe(e); + return Future.error(e); + } + } + + /// Returns the DownloadedSong of the given Flutter Downloader id. + /// Returns null if the item is not found. + DownloadedSong? getJellyfinItemFromDownloadId(String downloadId) { + try { + return _downloadIdsBox.get(downloadId); + } catch (e) { + _downloadsLogger.severe(e); + rethrow; + } + } + + /// Returns the DownloadedImage of the given Flutter Downloader id. + /// Returns null if the item is not found. + DownloadedImage? getDownloadedImageFromDownloadId(String downloadId) { + try { + return _downloadedImagesBox.get(_downloadedImageIdsBox.get(downloadId)); + } catch (e) { + _downloadsLogger.severe(e); + rethrow; + } + } + + /// Checks if an item with the key albumId exists in downloadedAlbumsBox. + bool isAlbumDownloaded(String albumId) { + try { + return _downloadedParentsBox.containsKey(albumId); + } catch (e) { + _downloadsLogger.severe(e); + rethrow; + } + } + + DownloadedSong? getDownloadedSong(String id) { + try { + return _downloadedItemsBox.get(id); + } catch (e) { + _downloadsLogger.severe(e); + rethrow; + } + } + + DownloadedParent? getDownloadedParent(String id) { + try { + return _downloadedParentsBox.get(id); + } catch (e) { + _downloadsLogger.severe(e); + rethrow; + } + } + + /// Checks if a DownloadedSong is actually downloaded, and fixes common issues + /// related to downloads (such as changed appdirs). Returns true if the song + /// is downloaded, and false otherwise. + Future verifyDownloadedSong(DownloadedSong downloadedSong) async { + if (downloadedSong.downloadLocation == null) { + _downloadsLogger.warning( + "Download location for ${downloadedSong.song.id} ${downloadedSong.song.name} returned null, looking for one now"); + + bool hasFoundLocation = false; + + final internalSongDir = await getInternalSongDir(); + final potentialSongFile = File(path_helper.join( + internalSongDir.path, path_helper.basename(downloadedSong.path))); + + // If the file exists in the (actual, not the potentially incorrect one + // stored) internal song dir, set the download location ID to the internal + // song dir and reset the stored internal song dir if it doesn't exist. + // Also make the song's path relative to this new location. + if (await potentialSongFile.exists()) { + _downloadsLogger.info( + "${downloadedSong.song.id} ${downloadedSong.song.name} exists at default internal song dir location, setting song to internal song dir"); + + downloadedSong.downloadLocationId = + FinampSettingsHelper.finampSettings.internalSongDir.id; + hasFoundLocation = true; + + if (!await Directory( + FinampSettingsHelper.finampSettings.internalSongDir.path) + .exists()) { + await FinampSettingsHelper.resetDefaultDownloadLocation(); + } + + downloadedSong.path = path_helper.relative(potentialSongFile.path, + from: downloadedSong.downloadLocation!.path); + + downloadedSong.isPathRelative = true; + addDownloadedSong(downloadedSong); + } else { + // Loop through all download locations. If we don't find one, assume the + // download location has been deleted. + FinampSettingsHelper.finampSettings.downloadLocationsMap + .forEach((key, value) { + if (downloadedSong.path.contains(value.path)) { + _downloadsLogger.info( + "Found download location (${value.name} ${value.id}), setting location for ${downloadedSong.song.id}"); + + downloadedSong.downloadLocationId = value.id; + + addDownloadedSong(downloadedSong); + hasFoundLocation = true; + } + }); + } + + if (!hasFoundLocation) { + _downloadsLogger.severe( + "Failed to find download location for ${downloadedSong.song.name} ${downloadedSong.song.id}! The download location may have been deleted."); + // return false; + } + } + + return await _verifyDownload(downloadedSong: downloadedSong); + } + + Future verifyDownloadedImage(DownloadedImage downloadedImage) async => + await _verifyDownload(downloadedImage: downloadedImage); + + Future _verifyDownload( + {DownloadedSong? downloadedSong, + DownloadedImage? downloadedImage}) async { + assert((downloadedSong == null) ^ (downloadedImage == null)); + + late String id; + late String downloadId; + late File file; + late DownloadLocation downloadLocation; // Checked before this func is run + late bool isPathRelative; + DownloadTask? downloadTask; + + if (downloadedSong != null) { + id = downloadedSong.song.id; + downloadId = downloadedSong.downloadId; + file = downloadedSong.file; + downloadLocation = downloadedSong.downloadLocation!; + isPathRelative = downloadedSong.isPathRelative; + downloadTask = await downloadedSong.downloadTask; + } else { + id = downloadedImage!.id; + downloadId = downloadedImage.downloadId; + file = downloadedImage.file; + downloadLocation = downloadedImage.downloadLocation!; + isPathRelative = true; + downloadTask = await downloadedImage.downloadTask; + } + + if (downloadTask == null) { + _downloadsLogger.severe( + "Download task list for $downloadId ($id) returned null, assuming item not downloaded"); + return false; + } + + if (downloadTask.status == DownloadTaskStatus.complete) { + _downloadsLogger.info("Song $id exists offline, using local file"); + + // Here we check if the file exists. This is important for + // human-readable files, since the user could have deleted the file. iOS + // also likes to move around the documents path after updates for some + // reason. + if (await file.exists()) { + return true; + } + + // Songs that don't have a deletable download location (internal storage) + // will be in the internal directory, so we check here first + if (!downloadLocation.deletable) { + _downloadsLogger.warning( + "${file.path} not found! Checking if the document directory has moved."); + + final currentDocumentsDirectory = + await getApplicationDocumentsDirectory(); + DownloadLocation internalStorageLocation = + FinampSettingsHelper.finampSettings.internalSongDir; + + // If the song path doesn't contain the current path, assume the + // path has changed. + if (!file.path.contains(currentDocumentsDirectory.path)) { + _downloadsLogger.warning( + "Item does not contain documents directory, assuming moved."); + + if (internalStorageLocation.path != + path_helper.join(currentDocumentsDirectory.path, "songs")) { + // Append /songs to the documents directory and create the new + // song dir if it doesn't exist for some reason. + final newSongDir = Directory( + path_helper.join(currentDocumentsDirectory.path, "songs")); + + _downloadsLogger.warning( + "Difference found in settings documents paths. Changing ${internalStorageLocation.path} to ${newSongDir.path} in settings."); + + // Set the new path in FinampSettings. + internalStorageLocation = + await FinampSettingsHelper.resetDefaultDownloadLocation(); + } + + // If the song's path is not relative, make it relative. This only + // handles songs since all images will have relative paths. + if (!isPathRelative) { + downloadedSong!.path = path_helper.relative(downloadedSong.path, + from: downloadedSong.downloadLocation!.path); + downloadedSong.isPathRelative = true; + addDownloadedSong(downloadedSong); + } + + if (await downloadedSong?.file.exists() ?? + await downloadedImage!.file.exists()) { + _downloadsLogger + .info("Found item in new path! Everything is fine™"); + return true; + } else { + _downloadsLogger.warning( + "$id not found in new path! Assuming that it was deleted before an update."); + } + } else { + _downloadsLogger.warning( + "The stored documents directory and the new one are both the same."); + } + } + // If the function has got to this point, the file was probably deleted. + + // If the file was not found, delete it in DownloadsHelper so that it properly shows as deleted. + _downloadsLogger.warning( + "${file.path} not found! Assuming deleted by user. Deleting with DownloadsHelper"); + deleteParentAndChildDownloads( + jellyfinItemIds: [id], + ); + + // If offline, throw an error. Otherwise, return false. + // TODO: This will need changing for #188 + if (FinampSettingsHelper.finampSettings.isOffline) { + return Future.error( + "File could not be found. Not falling back to online stream due to offline mode"); + } else { + return false; + } + } else { + if (FinampSettingsHelper.finampSettings.isOffline) { + return Future.error( + "Download is not complete, not adding. Wait for all downloads to be complete before playing."); + } else { + return false; + } + } + } + + DownloadedImage? getDownloadedImage(BaseItemDto item) { + if (item.blurHash != null) { + return _downloadedImagesBox.get(item.blurHash); + } else { + return null; + } + } + + /// Adds a song to the database. If a song with the same ID already exists, it + /// is overwritten. + void addDownloadedSong(DownloadedSong newDownloadedSong) => + _downloadedItemsBox.put(newDownloadedSong.song.id, newDownloadedSong); + + /// Checks all downloaded items/parents for missing images and downloads them. + /// Returns the amount of images downloaded. + Future downloadMissingImages() async { + // Get an iterable of downloaded items where the download has an image but + // that image isn't downloaded + Iterable missingItems = downloadedItems.where((element) => + element.song.blurHash != null && + !_downloadedImagesBox.containsKey(element.song.blurHash)); + + List> verifyFutures = []; + + // We check if the downloaded song is valid because we'll need a download + // location to download its image (download location will be null if made + // before 0.6, and most missing images will be because they were downloaded + // before 0.6) + for (final missingItem in missingItems) { + verifyFutures.add(verifyDownloadedSong(missingItem)); + } + + List verifyResults = await Future.wait(verifyFutures); + + // If any downloads were invalid, regenerate the iterable + if (verifyResults.contains(false)) { + missingItems = downloadedItems.where((element) => + element.song.blurHash != null && + !_downloadedImagesBox.containsKey(element.song.blurHash)); + } + + final List> downloadFutures = []; + + for (final missingItem in missingItems) { + downloadFutures.add(_downloadImage( + item: missingItem.song, + downloadDir: missingItem.file.parent, + downloadLocation: missingItem.downloadLocation!, + )); + } + + Iterable missingParents = downloadedParents.where( + (element) => + element.item.blurHash != null && + !_downloadedImagesBox.containsKey(element.item.blurHash)); + + verifyFutures = []; + + for (final missingParent in missingParents) { + // Since parents don't have their own location/path, we take their first + // child. + final downloadedSong = + getDownloadedSong(missingParent.downloadedChildren.values.first.id); + + if (downloadedSong == null) { + _downloadsLogger.warning( + "Failed to get downloaded song for parent ${missingParent.item.name}! This shouldn't happen..."); + continue; + } + + verifyFutures.add(verifyDownloadedSong(downloadedSong)); + } + + verifyResults = await Future.wait(verifyFutures); + + if (verifyResults.contains(false)) { + missingParents = downloadedParents.where((element) => + element.item.blurHash != null && + !_downloadedImagesBox.containsKey(element.item.blurHash)); + } + + for (final missingParent in missingParents) { + final downloadedSong = + getDownloadedSong(missingParent.downloadedChildren.values.first.id); + + if (downloadedSong == null) { + _downloadsLogger.warning( + "Failed to get downloaded song for parent ${missingParent.item.name}! This REALLY shouldn't happen..."); + continue; + } + + downloadFutures.add(_downloadImage( + item: missingParent.item, + downloadDir: downloadedSong.file.parent, + downloadLocation: downloadedSong.downloadLocation!, + )); + } + + await Future.wait(downloadFutures); + + return downloadFutures.length; + } + + /// Redownloads failed items. This is done by deleting the old downloads and + /// creating new ones with the same settings. Returns number of songs + /// redownloaded + Future redownloadFailed() async { + final failedDownloadTasks = + await getDownloadsWithStatus(DownloadTaskStatus.failed); + + if (failedDownloadTasks?.isEmpty ?? true) { + _downloadsLogger + .info("Failed downloads list is empty -> not redownloading anything"); + return 0; + } + + int redownloadCount = 0; + Map> parentItems = {}; + List deleteFutures = []; + List downloadedSongs = []; + + for (DownloadTask downloadTask in failedDownloadTasks!) { + DownloadedSong? downloadedSong = + getJellyfinItemFromDownloadId(downloadTask.taskId); + + if (downloadedSong == null) { + _downloadsLogger.info("Could not get Jellyfin item for failed task"); + continue; + } + + _downloadsLogger.info( + "Redownloading item ${downloadedSong.song.id} (${downloadedSong.song.name})"); + + downloadedSongs.add(downloadedSong); + + List parents = downloadedSong.requiredBy; + for (String parent in parents) { + // We don't specify deletedFor here because it could cause the parent + // to get deleted + deleteFutures + .add(deleteParentAndChildDownloads(jellyfinItemIds: [downloadedSong.song.id])); + + if (parentItems[downloadedSong.song.id] == null) { + parentItems[downloadedSong.song.id] = []; + } + + parentItems[downloadedSong.song.id]! + .add(await _jellyfinApiData.getItemById(parent)); + } + } + + await Future.wait(deleteFutures); + + for (final downloadedSong in downloadedSongs) { + final parents = parentItems[downloadedSong.song.id]; + + if (parents == null) { + _downloadsLogger.warning( + "Item ${downloadedSong.song.name} (${downloadedSong.song.id}) has no parent items, skipping"); + continue; + } + + for (final parent in parents) { + // We can't await all the downloads asynchronously as it could mess + // with setting up parents again + await addDownloads( + items: [downloadedSong.song], + parent: parent, + useHumanReadableNames: downloadedSong.useHumanReadableNames, + downloadLocation: downloadedSong.downloadLocation!, + viewId: downloadedSong.viewId, + ); + redownloadCount++; + } + } + + return redownloadCount; + } + + /// Migrates id-based images to blurhash-based images (for 0.6.15). Should + /// only be run if a migration has not been performed. + Future migrateBlurhashImages() async { + final Map imageMap = {}; + + _downloadsLogger.info("Performing image blurhash migration"); + + // Get a map to link blurhashes to images. This will be the list of images + // we keep. + for (final item in downloadedItems) { + final image = _downloadedImagesBox.get(item.song.id); + + if (image != null && item.song.blurHash != null) { + imageMap[item.song.blurHash!] = image; + } + } + + // Do above, but for parents. + for (final parent in downloadedParents) { + final image = _downloadedImagesBox.get(parent.item.id); + + if (image != null && parent.item.blurHash != null) { + imageMap[parent.item.blurHash!] = image; + } + } + + final imagesToKeep = imageMap.values.toSet(); + + // Get a list of all images not in the keep set + final imagesToDelete = downloadedImages + .where((element) => !imagesToKeep.contains(element)) + .toList(); + + for (final image in imagesToDelete) { + final song = getDownloadedSong(image.requiredBy.first); + + if (song != null) { + final blurHash = song.song.blurHash; + + imageMap[blurHash]?.requiredBy.addAll(image.requiredBy); + } + } + + // Go through each requiredBy and remove duplicates. We also set the image's + // id to the blurhash. + for (final imageEntry in imageMap.entries) { + final image = imageEntry.value; + + image.requiredBy = image.requiredBy.toSet().toList(); + _downloadsLogger.warning(image.requiredBy); + + image.id = imageEntry.key; + + imageMap[imageEntry.key] = image; + } + + // Sanity check to make sure we haven't double counted/missed an image. + final imagesCount = imagesToKeep.length + imagesToDelete.length; + if (imagesCount != downloadedImages.length) { + final err = + "Unexpected number of items in images to keep/delete! Expected ${downloadedImages.length}, got $imagesCount"; + _downloadsLogger.severe(err); + throw err; + } + + // Delete all images. + await Future.wait(imagesToDelete.map((e) => _deleteImage(e))); + + // Clear out the images box and put the kept images back in + await _downloadedImagesBox.clear(); + await _downloadedImagesBox.putAll(imageMap); + + // Do the same, but with the downloadId mapping + await _downloadedImageIdsBox.clear(); + await _downloadedImageIdsBox.putAll( + imageMap.map((key, value) => MapEntry(value.downloadId, value.id))); + + _downloadsLogger.info("Image blurhash migration complete."); + _downloadsLogger.info("${imagesToDelete.length} duplicate images deleted."); + } + + /// Fixes DownloadedImage IDs created by the migration in 0.6.15. In it, + /// migrated images did not have their IDs set to the blurhash. This function + /// sets every image's ID to its blurhash. This function should only be run + /// once, only when required (i.e., upgrading from 0.6.15). In theory, running + /// it on an unaffected database should do nothing, but there's no point doing + /// redundant migrations. + Future fixBlurhashMigrationIds() async { + _downloadsLogger.info("Fixing blurhash migration IDs from 0.6.15"); + + final List images = []; + + for (final image in downloadedImages) { + final item = getDownloadedSong(image.requiredBy.first) ?? + getDownloadedParent(image.requiredBy.first); + + if (item == null) { + // I should really use error enums when I rip this whole system out + throw "Failed to get item from image during blurhash migration fix!"; + } + + switch (item.runtimeType) { + case DownloadedSong: + image.id = (item as DownloadedSong).song.blurHash!; + break; + case DownloadedParent: + image.id = (item as DownloadedParent).item.blurHash!; + break; + default: + throw "Item was unexpected type! got ${item.runtimeType}. This really shouldn't happen..."; + } + + images.add(image); + } + + await _downloadedImagesBox.clear(); + await _downloadedImagesBox + .putAll(Map.fromEntries(images.map((e) => MapEntry(e.id, e)))); + + await _downloadedImageIdsBox.clear(); + await _downloadedImageIdsBox.putAll( + Map.fromEntries(images.map((e) => MapEntry(e.downloadId, e.id)))); + } + + Iterable get downloadedItems => _downloadedItemsBox.values; + + Iterable get downloadedImages => _downloadedImagesBox.values; + + ValueListenable> getDownloadedItemsListenable( + {List? keys}) => + _downloadedItemsBox.listenable(keys: keys); + + + /// Converts a dart list to a string with the correct SQL syntax + String _dartListToSqlList(List dartList) { + try { + String sqlList = "("; + int i = 0; + for (final element in dartList) { + sqlList += "'${element.toString()}'"; + if (i != (dartList.length - 1)) { + sqlList += ", "; + } + i++; + } + sqlList += ")"; + return sqlList; + } catch (e) { + _downloadsLogger.severe(e); + rethrow; + } + } + + /// Adds an item to a DownloadedAlbum's downloadedChildren map + void _addItemToDownloadedAlbum(String albumId, BaseItemDto item) { + try { + DownloadedParent? albumTemp = _downloadedParentsBox.get(albumId); + + if (albumTemp == null) { + } else { + albumTemp.downloadedChildren[item.id] = item; + _downloadedParentsBox.put(albumId, albumTemp); + } + } catch (e) { + _downloadsLogger.severe(e); + } + } + + /// Downloads the image for the given item. This function assumes that the + /// given item has an image. If the item does not have an image, the function + /// will throw an assert error. The function will return immediately if an + /// image with the same ID is already downloaded. + /// + /// As of 0.6.15, images are indexed by blurhash to ensure that duplicate + /// images are not downloaded (many albums will have an identical image + /// per-song). + Future _downloadImage({ + required BaseItemDto item, + required Directory downloadDir, + required DownloadLocation downloadLocation, + }) async { + assert(item.blurHash != null); + + if (_downloadedImagesBox.containsKey(item.blurHash)) return; + + final imageUrl = _jellyfinApiData.getImageUrl( + item: item, + // Download original file + quality: null, + format: null, + ); + final authHeader = await getAuthHeader(); + final relativePath = + path_helper.relative(downloadDir.path, from: downloadLocation.path); + + // We still use imageIds for filenames despite switching to blurhashes as + // blurhashes can include characters that filesystems don't support + final fileName = item.imageId; + + final imageDownloadId = await FlutterDownloader.enqueue( + url: imageUrl.toString(), + savedDir: downloadDir.path, + headers: { + "Authorization": authHeader, + }, + fileName: fileName, + openFileFromNotification: false, + showNotification: false, + ); + + if (imageDownloadId == null) { + _downloadsLogger.severe( + "Adding image download for ${item.blurHash} failed! downloadId is null. This only really happens if something goes horribly wrong with flutter_downloader's platform interface. This should never happen..."); + } + + final imageInfo = DownloadedImage.create( + id: item.blurHash!, + downloadId: imageDownloadId!, + path: path_helper.join(relativePath, fileName), + requiredBy: [item.id], + downloadLocationId: downloadLocation.id, + ); + + _addDownloadImageToDownloadedImages(imageInfo); + _downloadedImageIdsBox.put(imageDownloadId, imageInfo.id); + } + + /// Adds a [DownloadedImage] to the DownloadedImages box + void _addDownloadImageToDownloadedImages(DownloadedImage downloadedImage) { + _downloadedImagesBox.put(downloadedImage.id, downloadedImage); + } + + /// Get the download directory for the given item. Will create the directory + /// if it doesn't exist. + Future _getDownloadDirectory({ + required BaseItemDto item, + required Directory downloadBaseDir, + required bool useHumanReadableNames, + }) async { + late Directory directory; + + if (useHumanReadableNames) { + directory = + Directory(path_helper.join(downloadBaseDir.path, item.albumArtist)); + } else { + directory = Directory(downloadBaseDir.path); + } + + if (!await directory.exists()) { + await directory.create(); + } + + return directory; + } +} diff --git a/lib/services/finamp_logs_helper.dart b/lib/services/finamp_logs_helper.dart new file mode 100644 index 0000000..d9f2a61 --- /dev/null +++ b/lib/services/finamp_logs_helper.dart @@ -0,0 +1,49 @@ +import 'dart:io'; + +import 'package:clipboard/clipboard.dart'; +import 'package:finamp/services/censored_log.dart'; +import 'package:logging/logging.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:path/path.dart' as path_helper; + +class FinampLogsHelper { + final List logs = []; + + void addLog(LogRecord log) { + logs.add(log); + + // We don't want to keep logs forever due to memory constraints. + if (logs.length > 1000) { + logs.removeAt(0); + } + } + + /// Sanitises all logs and returns a massive string + String getSanitisedLogs() { + final logsStringBuffer = StringBuffer(); + + for (final log in logs) { + logsStringBuffer.writeln(log.censoredMessage); + } + + return logsStringBuffer.toString(); + } + + Future copyLogs() async => + await FlutterClipboard.copy(getSanitisedLogs()); + + /// Write logs to a file and share the file + Future shareLogs() async { + final tempDir = await getTemporaryDirectory(); + final tempFile = File(path_helper.join(tempDir.path, "finamp-logs.txt")); + + await tempFile.writeAsString(getSanitisedLogs()); + + final xFile = XFile(tempFile.path, mimeType: "text/plain"); + + await Share.shareXFiles([xFile]); + + await tempFile.delete(); + } +} diff --git a/lib/services/finamp_settings_helper.dart b/lib/services/finamp_settings_helper.dart new file mode 100644 index 0000000..116daf2 --- /dev/null +++ b/lib/services/finamp_settings_helper.dart @@ -0,0 +1,241 @@ +import 'package:flutter/foundation.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +import '../models/finamp_models.dart'; +import '../models/jellyfin_models.dart'; +import 'get_internal_song_dir.dart'; + +class FinampSettingsHelper { + static ValueListenable> get finampSettingsListener => + Hive.box("FinampSettings") + .listenable(keys: ["FinampSettings"]); + + // This shouldn't be null as FinampSettings is created on startup. + // This decision will probably come back to haunt me later. + static FinampSettings get finampSettings => + Hive.box("FinampSettings").get("FinampSettings")!; + + /// Deletes the downloadLocation at the given index. + static void deleteDownloadLocation(String id) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.downloadLocationsMap.remove(id); + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + + /// Add a new download location to FinampSettings + static void addDownloadLocation(DownloadLocation downloadLocation) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.downloadLocationsMap[downloadLocation.id] = + downloadLocation; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + + static Future resetDefaultDownloadLocation() async { + final newInternalSongDir = await getInternalSongDir(); + + FinampSettings finampSettingsTemp = finampSettings; + final internalSongDownloadLocation = finampSettingsTemp.internalSongDir; + + internalSongDownloadLocation.path = newInternalSongDir.path; + finampSettingsTemp.downloadLocationsMap[internalSongDownloadLocation.id] = + internalSongDownloadLocation; + + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + + return internalSongDownloadLocation; + } + + /// Set the isOffline property + static void setIsOffline(bool isOffline) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.isOffline = isOffline; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + + /// Set the shouldTranscode property + static void setShouldTranscode(bool shouldTranscode) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.shouldTranscode = shouldTranscode; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + + static void setTranscodeBitrate(int transcodeBitrate) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.transcodeBitrate = transcodeBitrate; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + + static void setShowTab(TabContentType tabContentType, bool value) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.showTabs[tabContentType] = value; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + + static void setIsFavourite(bool isFavourite) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.isFavourite = isFavourite; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + + static void setSortBy(TabContentType tabType, SortBy sortBy) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.tabSortBy[tabType] = sortBy; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + + static void setSortOrder(TabContentType tabType, SortOrder sortOrder) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.tabSortOrder[tabType] = sortOrder; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + + static void setAndroidStopForegroundOnPause( + bool androidStopForegroundOnPause) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.androidStopForegroundOnPause = + androidStopForegroundOnPause; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + + static void setSongShuffleItemCount(int songShuffleItemCount) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.songShuffleItemCount = songShuffleItemCount; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + + static void setContentGridViewCrossAxisCountPortrait( + int contentGridViewCrossAxisCountPortrait) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.contentGridViewCrossAxisCountPortrait = + contentGridViewCrossAxisCountPortrait; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + + static void setContentGridViewCrossAxisCountLandscape( + int contentGridViewCrossAxisCountLandscape) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.contentGridViewCrossAxisCountLandscape = + contentGridViewCrossAxisCountLandscape; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + + static void setContentViewType(ContentViewType contentViewType) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.contentViewType = contentViewType; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + + static void setShowTextOnGridView(bool showTextOnGridView) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.showTextOnGridView = showTextOnGridView; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + + static void setSleepTimerSeconds(int sleepTimerSeconds) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.sleepTimerSeconds = sleepTimerSeconds; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + + static void overwriteFinampSettings(FinampSettings newFinampSettings) { + Hive.box("FinampSettings") + .put("FinampSettings", newFinampSettings); + } + + static void setShowCoverAsPlayerBackground(bool showCoverAsPlayerBackground) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.showCoverAsPlayerBackground = + showCoverAsPlayerBackground; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + + static void setHideSongArtistsIfSameAsAlbumArtists( + bool hideSongArtistsIfSameAsAlbumArtists) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.hideSongArtistsIfSameAsAlbumArtists = + hideSongArtistsIfSameAsAlbumArtists; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + + static void setDisableGesture(bool disableGesture) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.disableGesture = disableGesture; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + + static void setShowFastScroller(bool showFastScroller) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.showFastScroller = showFastScroller; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + + static void setBufferDuration(Duration bufferDuration) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.bufferDuration = bufferDuration; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + + static void setHasCompletedBlurhashImageMigration( + bool hasCompletedBlurhashImageMigration) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.hasCompletedBlurhashImageMigration = + hasCompletedBlurhashImageMigration; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + + static void setHasCompletedBlurhashImageMigrationIdFix( + bool hasCompletedBlurhashImageMigrationIdFix) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.hasCompletedBlurhashImageMigrationIdFix = + hasCompletedBlurhashImageMigrationIdFix; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + + static void setTabOrder(int index, TabContentType tabContentType) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.tabOrder[index] = tabContentType; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + + static void resetTabs() { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.tabOrder = TabContentType.values; + finampSettingsTemp.showTabs = Map.fromEntries( + TabContentType.values.map( + (e) => MapEntry(e, true), + ), + ); + } + + static void setSwipeInsertQueueNext(bool swipeInsertQueueNext) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.swipeInsertQueueNext = swipeInsertQueueNext; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } +} diff --git a/lib/services/finamp_user_helper.dart b/lib/services/finamp_user_helper.dart new file mode 100644 index 0000000..f866291 --- /dev/null +++ b/lib/services/finamp_user_helper.dart @@ -0,0 +1,67 @@ +import 'package:flutter/foundation.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +import '../models/finamp_models.dart'; +import '../models/jellyfin_models.dart'; + +/// Helper class for Finamp users. Note that this class does not talk to the +/// Jellyfin server, so stuff like logging in/out is handled in JellyfinApiData. +class FinampUserHelper { + final _finampUserBox = Hive.box("FinampUsers"); + final _currentUserIdBox = Hive.box("CurrentUserId"); + + /// Checks if there are any saved users. + bool get isUsersEmpty => _finampUserBox.isEmpty; + + /// Loads the id from CurrentUserId. Returns null if no id is stored. + String? get currentUserId => _currentUserIdBox.get("CurrentUserId"); + + /// Loads the FinampUser with the id from CurrentUserId. Returns null if no + /// user exists. + FinampUser? get currentUser => + _finampUserBox.get(_currentUserIdBox.get("CurrentUserId")); + + ValueListenable> get finampUsersListenable => + _finampUserBox.listenable(); + + Iterable get finampUsers => _finampUserBox.values; + + /// Saves a new user to the Hive box and sets the CurrentUserId. + Future saveUser(FinampUser newUser) async { + await Future.wait([ + _finampUserBox.put(newUser.id, newUser), + _currentUserIdBox.put("CurrentUserId", newUser.id), + ]); + } + + /// Sets the views of the current user + void setCurrentUserViews(List newViews) { + final currentUserId = _currentUserIdBox.get("CurrentUserId"); + FinampUser currentUserTemp = currentUser!; + + currentUserTemp.views = Map.fromEntries( + newViews.map((e) => MapEntry(e.id, e))); + currentUserTemp.currentViewId = currentUserTemp.views.keys.first; + + _finampUserBox.put(currentUserId, currentUserTemp); + } + + void setCurrentUserCurrentViewId(String newViewId) { + final currentUserId = _currentUserIdBox.get("CurrentUserId"); + FinampUser currentUserTemp = currentUser!; + + currentUserTemp.currentViewId = newViewId; + + _finampUserBox.put(currentUserId, currentUserTemp); + } + + /// Removes the user with the given id. If the given id is the current user + /// id, CurrentUserId is cleared. + void removeUser(String id) { + if (id == _currentUserIdBox.get("CurrentUserId")) { + _currentUserIdBox.delete("CurrentUserId"); + } + + _finampUserBox.delete(id); + } +} \ No newline at end of file diff --git a/lib/services/generate_subtitle.dart b/lib/services/generate_subtitle.dart new file mode 100644 index 0000000..d9ba3cd --- /dev/null +++ b/lib/services/generate_subtitle.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../models/jellyfin_models.dart'; +import 'process_artist.dart'; + +/// Creates the subtitle text used on AlbumItemListTile and AlbumItemCard +String? generateSubtitle( + BaseItemDto item, String? parentType, BuildContext context) { + // TODO: Make it so that album subtitle on the artist screen isn't the artist's name (maybe something like the number of songs in the album) + + // If the parentType is MusicArtist, this is being called by an AlbumListTile in an AlbumView of an artist. + if (parentType == "MusicArtist") { + return item.productionYearString; + } + + switch (item.type) { + case "MusicAlbum": + return item.albumArtists != null && item.albumArtists!.isNotEmpty && (item.albumArtists!.length > 1 || item.albumArtists?.first.name != item.albumArtist) ? item.albumArtists?.map((e) => processArtist(e.name, context)).join(", ") : processArtist(item.albumArtist, context); + case "Playlist": + return AppLocalizations.of(context)!.songCount(item.childCount!); + // case "MusicGenre": + // case "MusicArtist": + // return Text("${item.albumCount} Albums"); + default: + return null; + } +} diff --git a/lib/services/get_internal_song_dir.dart b/lib/services/get_internal_song_dir.dart new file mode 100644 index 0000000..28f092e --- /dev/null +++ b/lib/services/get_internal_song_dir.dart @@ -0,0 +1,16 @@ +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path_helper; + +/// Returns the "internal storage" directory for songs (applicationDocumentsDirectory + /songs). +/// If it doesn't exist, the directory is created. +Future getInternalSongDir() async { + // TODO: Start using support directory by default, keep this around for legacy + Directory appDir = await getApplicationDocumentsDirectory(); + Directory songDir = Directory(path_helper.join(appDir.path, "songs")); + if (!await songDir.exists()) { + await songDir.create(); + } + return songDir; +} diff --git a/lib/services/http_aggregate_logging_interceptor.dart b/lib/services/http_aggregate_logging_interceptor.dart new file mode 100644 index 0000000..061ab4d --- /dev/null +++ b/lib/services/http_aggregate_logging_interceptor.dart @@ -0,0 +1,29 @@ +import 'dart:async'; + +import 'package:chopper/chopper.dart'; +import 'package:finamp/services/chopper_aggregate_logger.dart'; + +final aggregateLogger = ChopperAggregateLogger(); + +/// A HttpLoggingInterceptor that aggregates the request and +/// response logs from Chopper, using the [ChopperAggregateLogger]. +class HttpAggregateLoggingInterceptor extends HttpLoggingInterceptor { + HttpAggregateLoggingInterceptor({level = Level.body}) + : super(level: level, logger: aggregateLogger); + + @override + FutureOr onRequest(Request request) async { + aggregateLogger.onStartRequest(request); + final result = await super.onRequest(request); + aggregateLogger.onEndRequest(request); + return result; + } + + @override + FutureOr onResponse(Response response) { + aggregateLogger.onStartResponse(response); + final result = super.onResponse(response); + aggregateLogger.onEndResponse(response); + return result; + } +} diff --git a/lib/services/jellyfin_api.chopper.dart b/lib/services/jellyfin_api.chopper.dart new file mode 100644 index 0000000..05f9946 --- /dev/null +++ b/lib/services/jellyfin_api.chopper.dart @@ -0,0 +1,501 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'jellyfin_api.dart'; + +// ************************************************************************** +// ChopperGenerator +// ************************************************************************** + +// coverage:ignore-file +// ignore_for_file: type=lint +final class _$JellyfinApi extends JellyfinApi { + _$JellyfinApi([ChopperClient? client]) { + if (client == null) return; + this.client = client; + } + + @override + final Type definitionType = JellyfinApi; + + @override + Future getPublicUsers() async { + final Uri $url = Uri.parse('/Users/Public'); + final Request $request = Request( + 'GET', + $url, + client.baseUrl, + ); + final Response $response = await client.send( + $request, + requestConverter: JsonConverter.requestFactory, + responseConverter: JsonConverter.responseFactory, + ); + return $response.bodyOrThrow; + } + + @override + Future authenticateViaName( + Map usernameAndPassword) async { + final Uri $url = Uri.parse('/Users/AuthenticateByName'); + final $body = usernameAndPassword; + final Request $request = Request( + 'POST', + $url, + client.baseUrl, + body: $body, + ); + final Response $response = await client.send( + $request, + requestConverter: JsonConverter.requestFactory, + responseConverter: JsonConverter.responseFactory, + ); + return $response.bodyOrThrow; + } + + @override + Future getAlbumPrimaryImage({ + required String id, + String format = "webp", + }) async { + final Uri $url = Uri.parse('/Items/${id}/Images/Primary'); + final Map $params = {'format': format}; + final Request $request = Request( + 'GET', + $url, + client.baseUrl, + parameters: $params, + ); + final Response $response = await client.send( + $request, + requestConverter: JsonConverter.requestFactory, + responseConverter: JsonConverter.responseFactory, + ); + return $response.bodyOrThrow; + } + + @override + Future getViews(String id) async { + final Uri $url = Uri.parse('/Users/${id}/Views'); + final Request $request = Request( + 'GET', + $url, + client.baseUrl, + ); + final Response $response = await client.send( + $request, + requestConverter: JsonConverter.requestFactory, + responseConverter: JsonConverter.responseFactory, + ); + return $response.bodyOrThrow; + } + + @override + Future getItems({ + required String userId, + String? includeItemTypes, + String? parentId, + String? albumArtistIds, + String? artistIds, + String? albumIds, + bool? recursive, + String? sortBy, + String? sortOrder, + String? fields = defaultFields, + String? searchTerm, + String? genreIds, + String? filters, + int? startIndex, + int? limit, + }) async { + final Uri $url = Uri.parse('/Users/${userId}/Items'); + final Map $params = { + 'IncludeItemTypes': includeItemTypes, + 'ParentId': parentId, + 'AlbumArtistIds': albumArtistIds, + 'ArtistIds': artistIds, + 'AlbumIds': albumIds, + 'Recursive': recursive, + 'SortBy': sortBy, + 'SortOrder': sortOrder, + 'Fields': fields, + 'SearchTerm': searchTerm, + 'GenreIds': genreIds, + 'Filters': filters, + 'StartIndex': startIndex, + 'Limit': limit, + }; + final Request $request = Request( + 'GET', + $url, + client.baseUrl, + parameters: $params, + ); + final Response $response = await client.send( + $request, + requestConverter: JsonConverter.requestFactory, + responseConverter: JsonConverter.responseFactory, + ); + return $response.bodyOrThrow; + } + + @override + Future getInstantMix({ + required String id, + required String userId, + required int limit, + }) async { + final Uri $url = Uri.parse('/Items/${id}/InstantMix'); + final Map $params = { + 'userId': userId, + 'limit': limit, + }; + final Request $request = Request( + 'GET', + $url, + client.baseUrl, + parameters: $params, + ); + final Response $response = await client.send( + $request, + requestConverter: JsonConverter.requestFactory, + responseConverter: JsonConverter.responseFactory, + ); + return $response.bodyOrThrow; + } + + @override + Future getItemById({ + required String userId, + required String itemId, + }) async { + final Uri $url = Uri.parse('/Users/${userId}/Items/${itemId}'); + final Request $request = Request( + 'GET', + $url, + client.baseUrl, + ); + final Response $response = await client.send( + $request, + requestConverter: JsonConverter.requestFactory, + responseConverter: JsonConverter.responseFactory, + ); + return $response.bodyOrThrow; + } + + @override + Future getPlaybackInfo({ + required String id, + required String userId, + }) async { + final Uri $url = Uri.parse('/Items/${id}/PlaybackInfo'); + final Map $params = {'userId': userId}; + final Request $request = Request( + 'GET', + $url, + client.baseUrl, + parameters: $params, + ); + final Response $response = await client.send( + $request, + requestConverter: JsonConverter.requestFactory, + responseConverter: JsonConverter.responseFactory, + ); + return $response.bodyOrThrow; + } + + @override + Future updateItem({ + required String itemId, + required BaseItemDto newItem, + }) async { + final Uri $url = Uri.parse('/Items/${itemId}'); + final $body = newItem; + final Request $request = Request( + 'POST', + $url, + client.baseUrl, + body: $body, + ); + final Response $response = await client.send( + $request, + requestConverter: JsonConverter.requestFactory, + ); + return $response.bodyOrThrow; + } + + @override + Future startPlayback( + PlaybackProgressInfo playbackProgressInfo) async { + final Uri $url = Uri.parse('/Sessions/Playing'); + final $body = playbackProgressInfo; + final Request $request = Request( + 'POST', + $url, + client.baseUrl, + body: $body, + ); + final Response $response = await client.send( + $request, + requestConverter: JsonConverter.requestFactory, + ); + return $response.bodyOrThrow; + } + + @override + Future playbackStatusUpdate( + PlaybackProgressInfo playbackProgressInfo) async { + final Uri $url = Uri.parse('/Sessions/Playing/Progress'); + final $body = playbackProgressInfo; + final Request $request = Request( + 'POST', + $url, + client.baseUrl, + body: $body, + ); + final Response $response = await client.send( + $request, + requestConverter: JsonConverter.requestFactory, + ); + return $response.bodyOrThrow; + } + + @override + Future playbackStatusStopped( + PlaybackProgressInfo playbackProgressInfo) async { + final Uri $url = Uri.parse('/Sessions/Playing/Stopped'); + final $body = playbackProgressInfo; + final Request $request = Request( + 'POST', + $url, + client.baseUrl, + body: $body, + ); + final Response $response = await client.send( + $request, + requestConverter: JsonConverter.requestFactory, + ); + return $response.bodyOrThrow; + } + + @override + Future getPlaylistItems({ + required String playlistId, + required String userId, + String? includeItemTypes, + String? parentId, + bool? recursive, + String? fields = defaultFields, + }) async { + final Uri $url = Uri.parse('/Playlists/${playlistId}/Items'); + final Map $params = { + 'UserId': userId, + 'IncludeItemTypes': includeItemTypes, + 'ParentId': parentId, + 'Recursive': recursive, + 'Fields': fields, + }; + final Request $request = Request( + 'GET', + $url, + client.baseUrl, + parameters: $params, + ); + final Response $response = await client.send( + $request, + requestConverter: JsonConverter.requestFactory, + responseConverter: JsonConverter.responseFactory, + ); + return $response.bodyOrThrow; + } + + @override + Future createNewPlaylist({required NewPlaylist newPlaylist}) async { + final Uri $url = Uri.parse('/Playlists'); + final $body = newPlaylist; + final Request $request = Request( + 'POST', + $url, + client.baseUrl, + body: $body, + ); + final Response $response = await client.send( + $request, + requestConverter: JsonConverter.requestFactory, + responseConverter: JsonConverter.responseFactory, + ); + return $response.bodyOrThrow; + } + + @override + Future> addItemsToPlaylist({ + required String playlistId, + String? ids, + String? userId, + }) { + final Uri $url = Uri.parse('/Playlists/${playlistId}/Items'); + final Map $params = { + 'ids': ids, + 'userId': userId, + }; + final Request $request = Request( + 'POST', + $url, + client.baseUrl, + parameters: $params, + ); + return client.send( + $request, + requestConverter: JsonConverter.requestFactory, + ); + } + + @override + Future> removeItemsFromPlaylist({ + required String playlistId, + String? entryIds, + }) { + final Uri $url = Uri.parse('/Playlists/${playlistId}/Items'); + final Map $params = { + 'entryIds': entryIds + }; + final Request $request = Request( + 'DELETE', + $url, + client.baseUrl, + parameters: $params, + ); + return client.send( + $request, + requestConverter: JsonConverter.requestFactory, + ); + } + + @override + Future getAlbumArtists({ + String? includeItemTypes, + String? parentId, + bool? recursive, + String? sortBy, + String? sortOrder, + String? fields = defaultFields, + String? searchTerm, + bool enableUserData = true, + String? filters, + int? startIndex, + int? limit, + required String userId, + }) async { + final Uri $url = Uri.parse('/Artists/AlbumArtists'); + final Map $params = { + 'IncludeItemTypes': includeItemTypes, + 'ParentId': parentId, + 'Recursive': recursive, + 'SortBy': sortBy, + 'SortOrder': sortOrder, + 'Fields': fields, + 'SearchTerm': searchTerm, + 'EnableUserData': enableUserData, + 'Filters': filters, + 'StartIndex': startIndex, + 'Limit': limit, + 'UserId': userId, + }; + final Request $request = Request( + 'GET', + $url, + client.baseUrl, + parameters: $params, + ); + final Response $response = await client.send( + $request, + requestConverter: JsonConverter.requestFactory, + responseConverter: JsonConverter.responseFactory, + ); + return $response.bodyOrThrow; + } + + @override + Future getGenres({ + String? includeItemTypes, + String? parentId, + String? fields = defaultFields, + String? searchTerm, + int? startIndex, + int? limit, + }) async { + final Uri $url = Uri.parse('/Genres'); + final Map $params = { + 'IncludeItemTypes': includeItemTypes, + 'ParentId': parentId, + 'Fields': fields, + 'SearchTerm': searchTerm, + 'StartIndex': startIndex, + 'Limit': limit, + }; + final Request $request = Request( + 'GET', + $url, + client.baseUrl, + parameters: $params, + ); + final Response $response = await client.send( + $request, + requestConverter: JsonConverter.requestFactory, + responseConverter: JsonConverter.responseFactory, + ); + return $response.bodyOrThrow; + } + + @override + Future addFavourite({ + required String userId, + required String itemId, + }) async { + final Uri $url = Uri.parse('/Users/${userId}/FavoriteItems/${itemId}'); + final Request $request = Request( + 'POST', + $url, + client.baseUrl, + ); + final Response $response = await client.send( + $request, + requestConverter: JsonConverter.requestFactory, + responseConverter: JsonConverter.responseFactory, + ); + return $response.bodyOrThrow; + } + + @override + Future removeFavourite({ + required String userId, + required String itemId, + }) async { + final Uri $url = Uri.parse('/Users/${userId}/FavoriteItems/${itemId}'); + final Request $request = Request( + 'DELETE', + $url, + client.baseUrl, + ); + final Response $response = await client.send( + $request, + requestConverter: JsonConverter.requestFactory, + responseConverter: JsonConverter.responseFactory, + ); + return $response.bodyOrThrow; + } + + @override + Future logout() async { + final Uri $url = Uri.parse('/Sessions/Logout'); + final Request $request = Request( + 'POST', + $url, + client.baseUrl, + ); + final Response $response = await client.send( + $request, + requestConverter: JsonConverter.requestFactory, + ); + return $response.bodyOrThrow; + } +} diff --git a/lib/services/jellyfin_api.dart b/lib/services/jellyfin_api.dart new file mode 100644 index 0000000..b38e606 --- /dev/null +++ b/lib/services/jellyfin_api.dart @@ -0,0 +1,446 @@ +import 'dart:io' show Platform; + +import 'package:android_id/android_id.dart'; +import 'package:chopper/chopper.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:finamp/services/http_aggregate_logging_interceptor.dart'; +import 'package:get_it/get_it.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../models/jellyfin_models.dart'; +import 'finamp_user_helper.dart'; +import 'jellyfin_api_helper.dart'; + +part 'jellyfin_api.chopper.dart'; + +const String defaultFields = + "ChildCount,DateCreated,DateLastMediaAdded,Etag,Genres,ParentId,ProviderIds,Tags"; + +@ChopperApi() +abstract class JellyfinApi extends ChopperService { + @FactoryConverter( + request: JsonConverter.requestFactory, + response: JsonConverter.responseFactory, + ) + @Get(path: "/Users/Public") + Future getPublicUsers(); + + @FactoryConverter( + request: JsonConverter.requestFactory, + response: JsonConverter.responseFactory, + ) + @Post(path: "/Users/AuthenticateByName") + Future authenticateViaName( + @Body() Map usernameAndPassword); + + @FactoryConverter( + request: JsonConverter.requestFactory, + response: JsonConverter.responseFactory, + ) + @Get(path: "/Items/{id}/Images/Primary") + Future getAlbumPrimaryImage({ + @Path() required String id, + @Query() String format = "webp", + }); + + @FactoryConverter( + request: JsonConverter.requestFactory, + response: JsonConverter.responseFactory, + ) + @Get(path: "/Users/{id}/Views") + Future getViews(@Path() String id); + + @FactoryConverter( + request: JsonConverter.requestFactory, + response: JsonConverter.responseFactory, + ) + @Get(path: "/Users/{userId}/Items") + Future getItems({ + /// The user id supplied as query parameter. + @Path() required String userId, + + /// Optional. If specified, results will be filtered based on the item type. + /// This allows multiple, comma delimeted. + @Query("IncludeItemTypes") String? includeItemTypes, + + /// Specify this to localize the search to a specific item or folder. Omit + /// to use the root. + @Query("ParentId") String? parentId, + + /// Optional. If specified, results will be filtered to include only those + /// containing the specified album artist id. + @Query("AlbumArtistIds") String? albumArtistIds, + + /// Optional. If specified, results will be filtered to include only those + /// containing the specified artist id. + @Query("ArtistIds") String? artistIds, + + /// Optional. If specified, results will be filtered to include only those + /// containing the specified album id. + @Query("AlbumIds") String? albumIds, + + /// When searching within folders, this determines whether or not the search + /// will be recursive. true/false. + @Query("Recursive") bool? recursive, + + /// Optional. Specify one or more sort orders, comma delimited. Options: + /// Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, + /// DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, + /// SortName, Random, Revenue, Runtime. + @Query("SortBy") String? sortBy, + + /// Items Enum: "Ascending" "Descending" + /// Sort Order - Ascending,Descending. + @Query("SortOrder") String? sortOrder, + + /// Items Enum: "AirTime" "CanDelete" "CanDownload" "ChannelInfo" "Chapters" + /// "ChildCount" "CumulativeRunTimeTicks" "CustomRating" "DateCreated" + /// "DateLastMediaAdded" "DisplayPreferencesId" "Etag" "ExternalUrls" + /// "Genres" "HomePageUrl" "ItemCounts" "MediaSourceCount" "MediaSources" + /// "OriginalTitle" "Overview" "ParentId" "Path" "People" "PlayAccess" + /// "ProductionLocations" "ProviderIds" "PrimaryImageAspectRatio" + /// "RecursiveItemCount" "Settings" "ScreenshotImageTags" + /// "SeriesPrimaryImage" "SeriesStudio" "SortName" "SpecialEpisodeNumbers" + /// "Studios" "BasicSyncInfo" "SyncInfo" "Taglines" "Tags" "RemoteTrailers" + /// "MediaStreams" "SeasonUserData" "ServiceName" "ThemeSongIds" + /// "ThemeVideoIds" "ExternalEtag" "PresentationUniqueKey" + /// "InheritedParentalRatingValue" "ExternalSeriesId" + /// "SeriesPresentationUniqueKey" "DateLastRefreshed" "DateLastSaved" + /// "RefreshState" "ChannelImage" "EnableMediaSourceDisplay" "Width" + /// "Height" "ExtraIds" "LocalTrailerCount" "IsHD" "SpecialFeatureCount" + @Query("Fields") String? fields = defaultFields, + + /// Optional. Filter based on a search term. + @Query("SearchTerm") String? searchTerm, + + /// Optional. If specified, results will be filtered based on genre id. This + /// allows multiple, pipe delimited. + @Query("GenreIds") String? genreIds, + + /// Items Enum: "IsFolder" "IsNotFolder" "IsUnplayed" "IsPlayed" + /// "IsFavorite" "IsResumable" "Likes" "Dislikes" "IsFavoriteOrLikes" + /// Optional. Specify additional filters to apply. This allows multiple, + /// comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, + /// IsFavorite, IsResumable, Likes, Dislikes. + @Query("Filters") String? filters, + + /// Optional. The record index to start at. All items with a lower index + /// will be dropped from the results. + @Query("StartIndex") int? startIndex, + + /// Optional. The maximum number of records to return. + @Query("Limit") int? limit, + }); + + @FactoryConverter( + request: JsonConverter.requestFactory, + response: JsonConverter.responseFactory, + ) + @Get(path: "/Items/{id}/InstantMix") + Future getInstantMix({ + @Path() required String id, + @Query() required String userId, + @Query() required int limit, + }); + + @FactoryConverter( + request: JsonConverter.requestFactory, + response: JsonConverter.responseFactory, + ) + @Get(path: "/Users/{userId}/Items/{itemId}") + Future getItemById({ + /// User id. + @Path() required String userId, + + /// Item id. + @Path() required String itemId, + }); + + @FactoryConverter( + request: JsonConverter.requestFactory, + response: JsonConverter.responseFactory, + ) + @Get(path: "/Items/{id}/PlaybackInfo") + Future getPlaybackInfo({ + @Path() required String id, + @Query() required String userId, + }); + + @FactoryConverter( + request: JsonConverter.requestFactory, + ) + @Post(path: "/Items/{itemId}") + Future updateItem({ + /// The item id. + @Path() required String itemId, + @Body() required BaseItemDto newItem, + }); + + @FactoryConverter(request: JsonConverter.requestFactory) + @Post(path: "/Sessions/Playing") + Future startPlayback( + @Body() PlaybackProgressInfo playbackProgressInfo); + + @FactoryConverter(request: JsonConverter.requestFactory) + @Post(path: "/Sessions/Playing/Progress") + Future playbackStatusUpdate( + @Body() PlaybackProgressInfo playbackProgressInfo); + + @FactoryConverter(request: JsonConverter.requestFactory) + @Post(path: "/Sessions/Playing/Stopped") + Future playbackStatusStopped( + @Body() PlaybackProgressInfo playbackProgressInfo); + + @FactoryConverter( + request: JsonConverter.requestFactory, + response: JsonConverter.responseFactory, + ) + @Get(path: "/Playlists/{playlistId}/Items") + Future getPlaylistItems({ + @Path() required String playlistId, + @Query("UserId") required String userId, + @Query("IncludeItemTypes") String? includeItemTypes, + @Query("ParentId") String? parentId, + @Query("Recursive") bool? recursive, + @Query("Fields") String? fields = defaultFields, + }); + + /// Creates a new playlist. + @FactoryConverter( + request: JsonConverter.requestFactory, + response: JsonConverter.responseFactory, + ) + @Post(path: "/Playlists") + Future createNewPlaylist({ + /// The create playlist payload. + @Body() required NewPlaylist newPlaylist, + }); + + /// Adds items to a playlist. + @FactoryConverter(request: JsonConverter.requestFactory) + @Post(path: "/Playlists/{playlistId}/Items", optionalBody: true) + Future addItemsToPlaylist({ + /// The playlist id. + @Path() required String playlistId, + + /// Item id, comma delimited. + @Query() String? ids, + + /// The userId. + @Query() String? userId, + }); + + /// Remove items from a playlist. + @FactoryConverter(request: JsonConverter.requestFactory) + @Delete(path: "/Playlists/{playlistId}/Items", optionalBody: true) + Future removeItemsFromPlaylist({ + /// The playlist id. + @Path() required String playlistId, + + /// Item id, comma delimited. + @Query() String? entryIds, + }); + + @FactoryConverter( + request: JsonConverter.requestFactory, + response: JsonConverter.responseFactory, + ) + @Get(path: "/Artists/AlbumArtists") + Future getAlbumArtists({ + @Query("IncludeItemTypes") String? includeItemTypes, + @Query("ParentId") String? parentId, + @Query("Recursive") bool? recursive, + + /// Optional. Specify one or more sort orders, comma delimited. Options: + /// Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, + /// DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, + /// SortName, Random, Revenue, Runtime. + @Query("SortBy") String? sortBy, + + /// Items Enum: "Ascending" "Descending" + /// Sort Order - Ascending,Descending. + @Query("SortOrder") String? sortOrder, + @Query("Fields") String? fields = defaultFields, + @Query("SearchTerm") String? searchTerm, + @Query("EnableUserData") bool enableUserData = true, + + /// Items Enum: "IsFolder" "IsNotFolder" "IsUnplayed" "IsPlayed" + /// "IsFavorite" "IsResumable" "Likes" "Dislikes" "IsFavoriteOrLikes" + /// Optional. Specify additional filters to apply. + @Query("Filters") String? filters, + + /// Optional. The record index to start at. All items with a lower index + /// will be dropped from the results. + @Query("StartIndex") int? startIndex, + + /// Optional. The maximum number of records to return. + @Query("Limit") int? limit, + + /// User id. Technically nullable in the Jellyfin API docs, but getting + /// favourited artists will break if this is not given. + @Query("UserId") required String userId, + }); + + /// Gets all genres from a given item, folder, or the entire library. + @FactoryConverter( + request: JsonConverter.requestFactory, + response: JsonConverter.responseFactory, + ) + @Get(path: "/Genres") + Future getGenres({ + /// Optional. If specified, results will be filtered based on the item type. + /// This allows multiple, comma delimeted. + @Query("IncludeItemTypes") String? includeItemTypes, + + /// Specify this to localize the search to a specific item or folder. Omit + /// to use the root. + @Query("ParentId") String? parentId, + + /// Items Enum: "AirTime" "CanDelete" "CanDownload" "ChannelInfo" "Chapters" + /// "ChildCount" "CumulativeRunTimeTicks" "CustomRating" "DateCreated" + /// "DateLastMediaAdded" "DisplayPreferencesId" "Etag" "ExternalUrls" + /// "Genres" "HomePageUrl" "ItemCounts" "MediaSourceCount" "MediaSources" + /// "OriginalTitle" "Overview" "ParentId" "Path" "People" "PlayAccess" + /// "ProductionLocations" "ProviderIds" "PrimaryImageAspectRatio" + /// "RecursiveItemCount" "Settings" "ScreenshotImageTags" + /// "SeriesPrimaryImage" "SeriesStudio" "SortName" "SpecialEpisodeNumbers" + /// "Studios" "BasicSyncInfo" "SyncInfo" "Taglines" "Tags" "RemoteTrailers" + /// "MediaStreams" "SeasonUserData" "ServiceName" "ThemeSongIds" + /// "ThemeVideoIds" "ExternalEtag" "PresentationUniqueKey" + /// "InheritedParentalRatingValue" "ExternalSeriesId" + /// "SeriesPresentationUniqueKey" "DateLastRefreshed" "DateLastSaved" + /// "RefreshState" "ChannelImage" "EnableMediaSourceDisplay" "Width" + /// "Height" "ExtraIds" "LocalTrailerCount" "IsHD" "SpecialFeatureCount" + @Query("Fields") String? fields = defaultFields, + + /// Optional. Filter based on a search term. + @Query("SearchTerm") String? searchTerm, + + /// Optional. The record index to start at. All items with a lower index + /// will be dropped from the results. + @Query("StartIndex") int? startIndex, + + /// Optional. The maximum number of records to return. + @Query("Limit") int? limit, + }); + + /// Marks an item as a favorite. + @FactoryConverter( + request: JsonConverter.requestFactory, + response: JsonConverter.responseFactory, + ) + @Post(path: "/Users/{userId}/FavoriteItems/{itemId}", optionalBody: true) + Future addFavourite({ + /// User id. + @Path() required String userId, + + /// Item id. + @Path() required String itemId, + }); + + /// Unmarks item as a favorite. + @FactoryConverter( + request: JsonConverter.requestFactory, + response: JsonConverter.responseFactory, + ) + @Delete(path: "/Users/{userId}/FavoriteItems/{itemId}") + Future removeFavourite({ + /// User id. + @Path() required String userId, + + /// Item id. + @Path() required String itemId, + }); + + /// Reports that a session has ended. + @FactoryConverter( + request: JsonConverter.requestFactory, + ) + @Post(path: "/Sessions/Logout", optionalBody: true) + Future logout(); + + static JellyfinApi create() { + final client = ChopperClient( + // The first part of the URL is now here + services: [ + // The generated implementation + _$JellyfinApi(), + ], + // Converts data to & from JSON and adds the application/json header. + // converter: JsonConverter(), + interceptors: [ + /// Gets baseUrl from SharedPreferences. + (Request request) async { + final jellyfinApiHelper = GetIt.instance(); + final finampUserHelper = GetIt.instance(); + + String authHeader = await getAuthHeader(); + + // If baseUrlTemp is null, use the baseUrl of the current user. + // If baseUrlTemp is set, we're setting up a new user and should use it instead. + Uri baseUri = jellyfinApiHelper.baseUrlTemp ?? + Uri.parse(finampUserHelper.currentUser!.baseUrl); + + // Add the request path on to the baseUrl + baseUri = baseUri.replace( + pathSegments: + baseUri.pathSegments.followedBy(request.uri.pathSegments)); + + return request.copyWith( + uri: baseUri, + headers: { + "Content-Type": "application/json", + "Authorization": authHeader, + }, + ); + }, + + HttpAggregateLoggingInterceptor(), + ], + ); + + // The generated class with the ChopperClient passed in + return _$JellyfinApi(client); + } +} + +/// Creates the Authorization header +Future getAuthHeader() async { + final notAsciiRegex = RegExp(r'[^\x00-\x7F]+'); + + final finampUserHelper = GetIt.instance(); + + String authHeader = "MediaBrowser "; + + if (finampUserHelper.currentUser != null) { + authHeader = '${authHeader}UserId="${finampUserHelper.currentUser!.id}", '; + } + + if (finampUserHelper.currentUser?.accessToken != null) { + authHeader = + '${authHeader}Token="${finampUserHelper.currentUser!.accessToken}", '; + } + + authHeader = '${authHeader}Client="Finamp", '; + DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); + if (Platform.isAndroid) { + AndroidDeviceInfo androidDeviceInfo = await deviceInfo.androidInfo; + authHeader = '${authHeader}Device="${androidDeviceInfo.model}", '; + final androidId = await const AndroidId().getId(); + authHeader = '${authHeader}DeviceId="$androidId", '; + } else if (Platform.isIOS) { + IosDeviceInfo iosDeviceInfo = await deviceInfo.iosInfo; + authHeader = '${authHeader}Device="${iosDeviceInfo.name}", '; + authHeader = + '${authHeader}DeviceId="${iosDeviceInfo.identifierForVendor}", '; + } else { + throw "getAuthHeader() only supports Android and iOS"; + } + + PackageInfo packageInfo = await PackageInfo.fromPlatform(); + authHeader = '${authHeader}Version="${packageInfo.version}"'; + + // In some cases non-ASCII characters can end up in the header, usually via + // iOS device name + return authHeader.replaceAll(notAsciiRegex, "_"); +} diff --git a/lib/services/jellyfin_api_helper.dart b/lib/services/jellyfin_api_helper.dart new file mode 100644 index 0000000..91741a1 --- /dev/null +++ b/lib/services/jellyfin_api_helper.dart @@ -0,0 +1,388 @@ +import 'package:chopper/chopper.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logging/logging.dart'; + +import 'finamp_user_helper.dart'; +import 'jellyfin_api.dart'; +import '../models/finamp_models.dart'; +import '../models/jellyfin_models.dart'; + +class JellyfinApiHelper { + final jellyfinApi = JellyfinApi.create(); + final _jellyfinApiHelperLogger = Logger("JellyfinApiHelper"); + + // Stores the ids of the artists that the user selected to mix + List selectedMixArtistsIds = []; + + // Stores the ids of albums that the user selected to mix + List selectedMixAlbumIds = []; + + Uri? baseUrlTemp; + + final _finampUserHelper = GetIt.instance(); + + Future?> getItems({ + BaseItemDto? parentItem, + String? includeItemTypes, + String? sortBy, + String? sortOrder, + String? searchTerm, + required bool isGenres, + String? filters, + + /// The record index to start at. All items with a lower index will be + /// dropped from the results. + int? startIndex, + + /// The maximum number of records to return. + int? limit, + }) async { + final currentUser = _finampUserHelper.currentUser; + if (currentUser == null) { + // When logging out, this request causes errors since currentUser is + // required sometimes. We just return an empty list since this error + // usually happens becuase the listeners on MusicScreenTabView update + // milliseconds before the page is popped. This shouldn't happen in normal + // use. + return []; + } + + var response; + + // We send a different request for playlists so that we get them back in the + // right order. Doing this in the same function makes sense since they both + // return the same thing. It also means we can easily share album widgets + // with playlists. + if (parentItem?.type == "Playlist") { + response = await jellyfinApi.getPlaylistItems( + playlistId: parentItem!.id, + userId: currentUser.id, + parentId: parentItem.id, + includeItemTypes: includeItemTypes, + recursive: true, + ); + } else if (includeItemTypes == "MusicArtist") { + // For artists, we need to use a different endpoint + response = await jellyfinApi.getAlbumArtists( + parentId: parentItem?.id, + recursive: true, + sortBy: sortBy, + sortOrder: sortOrder, + searchTerm: searchTerm, + filters: filters, + startIndex: startIndex, + limit: limit, + userId: currentUser.id, + ); + } else if (parentItem?.type == "MusicArtist") { + // For getting the children of artists, we need to use albumArtistIds + // instead of parentId + response = await jellyfinApi.getItems( + userId: currentUser.id, + albumArtistIds: parentItem?.id, + includeItemTypes: includeItemTypes, + recursive: true, + sortBy: sortBy, + sortOrder: sortOrder, + searchTerm: searchTerm, + filters: filters, + startIndex: startIndex, + limit: limit, + ); + } else if (includeItemTypes == "MusicGenre") { + response = await jellyfinApi.getGenres( + parentId: parentItem?.id, + // includeItemTypes: includeItemTypes, + searchTerm: searchTerm, + startIndex: startIndex, + limit: limit, + ); + } else if (parentItem?.type == "MusicGenre") { + response = await jellyfinApi.getItems( + userId: currentUser.id, + genreIds: parentItem?.id, + includeItemTypes: includeItemTypes, + recursive: true, + sortBy: sortBy, + sortOrder: sortOrder, + searchTerm: searchTerm, + filters: filters, + startIndex: startIndex, + limit: limit, + ); + } else { + // This will be run when getting albums, songs in albums, and stuff like + // that. + response = await jellyfinApi.getItems( + userId: currentUser.id, + parentId: parentItem?.id, + includeItemTypes: includeItemTypes, + recursive: true, + sortBy: sortBy, + sortOrder: sortOrder, + searchTerm: searchTerm, + filters: filters, + startIndex: startIndex, + limit: limit, + ); + } + + return (QueryResult_BaseItemDto.fromJson(response).items); + } + + /// Authenticates a user and saves the login details + Future authenticateViaName({ + required String username, + String? password, + }) async { + var response; + + // Some users won't have a password. + if (password == null) { + response = await jellyfinApi.authenticateViaName({"Username": username}); + } else { + response = await jellyfinApi + .authenticateViaName({"Username": username, "Pw": password}); + } + + AuthenticationResult newUserAuthenticationResult = + AuthenticationResult.fromJson(response); + + FinampUser newUser = FinampUser( + id: newUserAuthenticationResult.user!.id, + baseUrl: baseUrlTemp!.toString(), + accessToken: newUserAuthenticationResult.accessToken!, + serverId: newUserAuthenticationResult.serverId!, + views: {}, + ); + + await _finampUserHelper.saveUser(newUser); + } + + /// Gets all the user's views. + Future> getViews() async { + var response = + await jellyfinApi.getViews(_finampUserHelper.currentUser!.id); + + return QueryResult_BaseItemDto.fromJson(response).items!; + } + + /// Gets the playback info for an item, such as format and bitrate. Usually, I'd require a BaseItemDto as an argument + /// but since this will be run inside of [MusicPlayerBackgroundTask], I've just set the raw id as an argument. + Future?> getPlaybackInfo(String itemId) async { + var response = await jellyfinApi.getPlaybackInfo( + id: itemId, + userId: _finampUserHelper.currentUser!.id, + ); + + // getPlaybackInfo returns a PlaybackInfoResponse. We only need the List in it so we convert it here and + // return that List. + final PlaybackInfoResponse decodedResponse = + PlaybackInfoResponse.fromJson(response); + return decodedResponse.mediaSources; + } + + /// Starts an instant mix using the data from the item provided. + Future?> getInstantMix(BaseItemDto? parentItem) async { + var response = await jellyfinApi.getInstantMix( + id: parentItem!.id, + userId: _finampUserHelper.currentUser!.id, + limit: 200); + + return (QueryResult_BaseItemDto.fromJson(response).items); + } + + /// Tells the Jellyfin server that playback has started + Future reportPlaybackStart( + PlaybackProgressInfo playbackProgressInfo) async { + await jellyfinApi.startPlayback(playbackProgressInfo); + } + + /// Updates player progress so that Jellyfin can track what we're listening to + Future updatePlaybackProgress( + PlaybackProgressInfo playbackProgressInfo) async { + await jellyfinApi.playbackStatusUpdate(playbackProgressInfo); + } + + /// Tells Jellyfin that we've stopped listening to music (called when the audio service is stopped) + Future stopPlaybackProgress( + PlaybackProgressInfo playbackProgressInfo) async { + await jellyfinApi.playbackStatusStopped(playbackProgressInfo); + } + + /// Gets an item from a user's library. + Future getItemById(String itemId) async { + var response = await jellyfinApi.getItemById( + userId: _finampUserHelper.currentUser!.id, + itemId: itemId, + ); + + return (BaseItemDto.fromJson(response)); + } + + /// Creates a new playlist. + Future createNewPlaylist(NewPlaylist newPlaylist) async { + var response = await jellyfinApi.createNewPlaylist( + newPlaylist: newPlaylist, + ); + + return NewPlaylistResponse.fromJson(response); + } + + /// Adds items to a playlist. + Future addItemstoPlaylist({ + /// The playlist id. + required String playlistId, + + /// Item ids to add. + List? ids, + }) async { + await jellyfinApi.addItemsToPlaylist( + playlistId: playlistId, + ids: ids?.join(","), + ); + } + + /// Remove items to a playlist. + Future removeItemsFromPlaylist({ + /// The playlist id. + required String playlistId, + + /// Item ids to add. + List? entryIds, + }) async { + await jellyfinApi.removeItemsFromPlaylist( + playlistId: playlistId, + entryIds: entryIds?.join(","), + ); + } + + /// Updates an item. + Future updateItem({ + /// The item id. + required String itemId, + + /// What to update the item with. You should give a BaseItemDto with only + /// changed values. + required BaseItemDto newItem, + }) async { + await jellyfinApi.updateItem(itemId: itemId, newItem: newItem); + } + + /// Marks an item as a favorite. + Future addFavourite(String itemId) async { + var response = await jellyfinApi.addFavourite( + userId: _finampUserHelper.currentUser!.id, itemId: itemId); + + return UserItemDataDto.fromJson(response); + } + + /// Unmarks item as a favorite. + Future removeFavourite(String itemId) async { + var response = await jellyfinApi.removeFavourite( + userId: _finampUserHelper.currentUser!.id, itemId: itemId); + + return UserItemDataDto.fromJson(response); + } + + void addArtistToMixBuilderList(BaseItemDto item) { + selectedMixArtistsIds.add(item.id); + } + + void removeArtistFromBuilderList(BaseItemDto item) { + selectedMixArtistsIds.remove(item.id); + } + + void addAlbumToMixBuilderList(BaseItemDto item) { + selectedMixAlbumIds.add(item.id); + } + + void removeAlbumFromBuilderList(BaseItemDto item) { + selectedMixAlbumIds.remove(item.id); + } + + Future?> getArtistMix(List artistIds) async { + var response = await jellyfinApi.getItems( + userId: _finampUserHelper.currentUser!.id, + artistIds: artistIds.join(","), + filters: "IsNotFolder", + recursive: true, + sortBy: "Random", + limit: 300, + fields: "Chapters"); + + return (QueryResult_BaseItemDto.fromJson(response).items); + } + + Future?> getAlbumMix(List albumIds) async { + var response = await jellyfinApi.getItems( + userId: _finampUserHelper.currentUser!.id, + albumIds: albumIds.join(","), + filters: "IsNotFolder", + recursive: true, + sortBy: "Random", + limit: 300, + fields: "Chapters"); + + return (QueryResult_BaseItemDto.fromJson(response).items); + } + + /// Removes the current user from the DB and revokes the token on Jellyfin + Future logoutCurrentUser() async { + // We put this in a try-catch loop that basically ignores errors so that the + // user can still log out during scenarios like wrong IP, no internet etc. + + try { + await jellyfinApi.logout().timeout( + const Duration(seconds: 3), + onTimeout: () => _jellyfinApiHelperLogger.warning( + "Logout request timed out. Logging out anyway, but be aware that Jellyfin may have not got the signal."), + ); + } catch (e) { + _jellyfinApiHelperLogger.warning( + "Jellyfin logout failed. Logging out anyway, but be aware that Jellyfin may have not got the signal.", + e); + } finally { + // If we're unauthorised, the logout command will fail but we're already + // basically logged out so we shouldn't fail. + _finampUserHelper.removeUser(_finampUserHelper.currentUser!.id); + } + } + + /// Returns the correct image URL for the given item, or null if there is no + /// image. Uses [getImageId] to get the actual id. [maxWidth] and [maxHeight] + /// can be specified to return a smaller image. [quality] can be modified to + /// get a higher/lower quality image. + Uri? getImageUrl({ + required BaseItemDto item, + int? maxWidth, + int? maxHeight, + int? quality = 90, + String? format = "jpg", + }) { + if (item.imageId == null) { + return null; + } + + final parsedBaseUrl = Uri.parse(_finampUserHelper.currentUser!.baseUrl); + List builtPath = List.from(parsedBaseUrl.pathSegments); + builtPath.addAll([ + "Items", + item.imageId!, + "Images", + "Primary", + ]); + return Uri( + host: parsedBaseUrl.host, + port: parsedBaseUrl.port, + scheme: parsedBaseUrl.scheme, + userInfo: parsedBaseUrl.userInfo, + pathSegments: builtPath, + queryParameters: { + if (format != null) "format": format, + if (quality != null) "quality": quality.toString(), + if (maxWidth != null) "MaxWidth": maxWidth.toString(), + if (maxHeight != null) "MaxHeight": maxHeight.toString(), + }); + } +} diff --git a/lib/services/locale_helper.dart b/lib/services/locale_helper.dart new file mode 100644 index 0000000..34f401d --- /dev/null +++ b/lib/services/locale_helper.dart @@ -0,0 +1,16 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +class LocaleHelper { + static const boxName = "Locale"; + + static ValueListenable> get localeListener => + Hive.box(boxName).listenable(); + + static Locale? get locale => Hive.box(boxName).get(boxName); + + static void setLocale(Locale? locale) { + Hive.box(boxName).put(boxName, locale); + } +} diff --git a/lib/services/media_state_stream.dart b/lib/services/media_state_stream.dart new file mode 100644 index 0000000..a562175 --- /dev/null +++ b/lib/services/media_state_stream.dart @@ -0,0 +1,22 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:get_it/get_it.dart'; +import 'package:rxdart/rxdart.dart'; + +import 'music_player_background_task.dart'; + +class MediaState { + final MediaItem? mediaItem; + final PlaybackState playbackState; + + MediaState(this.mediaItem, this.playbackState); +} + +/// A stream reporting the combined state of the current media item and its +/// current position. +Stream get mediaStateStream { + final audioHandler = GetIt.instance(); + return Rx.combineLatest2( + audioHandler.mediaItem, + audioHandler.playbackState, + (mediaItem, playbackState) => MediaState(mediaItem, playbackState)); +} diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart new file mode 100644 index 0000000..9292148 --- /dev/null +++ b/lib/services/music_player_background_task.dart @@ -0,0 +1,798 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:android_id/android_id.dart'; +import 'package:audio_service/audio_service.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:finamp/services/offline_listen_helper.dart'; +import 'package:flutter/foundation.dart'; +import 'package:get_it/get_it.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:logging/logging.dart'; + +import '../models/finamp_models.dart'; +import '../models/jellyfin_models.dart'; +import 'finamp_settings_helper.dart'; +import 'finamp_user_helper.dart'; +import 'jellyfin_api_helper.dart'; + +// Largely copied from just_audio's DefaultShuffleOrder, but with a mildly +// stupid hack to insert() to make Play Next work +class FinampShuffleOrder extends ShuffleOrder { + final Random _random; + @override + final indices = []; + + FinampShuffleOrder({Random? random}) : _random = random ?? Random(); + + @override + void shuffle({int? initialIndex}) { + assert(initialIndex == null || indices.contains(initialIndex)); + if (indices.length <= 1) return; + indices.shuffle(_random); + if (initialIndex == null) return; + + const initialPos = 0; + final swapPos = indices.indexOf(initialIndex); + // Swap the indices at initialPos and swapPos. + final swapIndex = indices[initialPos]; + indices[initialPos] = initialIndex; + indices[swapPos] = swapIndex; + } + + @override + void insert(int index, int count) { + // Offset indices after insertion point. + for (var i = 0; i < indices.length; i++) { + if (indices[i] >= index) { + indices[i] += count; + } + } + + final newIndices = List.generate(count, (i) => index + i); + // This is the only modification from DefaultShuffleOrder: Only shuffle + // inserted indices amongst themselves, but keep them contiguous + newIndices.shuffle(_random); + indices.insertAll(index, newIndices); + } + + @override + void removeRange(int start, int end) { + final count = end - start; + // Remove old indices. + final oldIndices = List.generate(count, (i) => start + i).toSet(); + indices.removeWhere(oldIndices.contains); + // Offset indices after deletion point. + for (var i = 0; i < indices.length; i++) { + if (indices[i] >= end) { + indices[i] -= count; + } + } + } + + @override + void clear() { + indices.clear(); + } +} + +/// This provider handles the currently playing music so that multiple widgets +/// can control music. +class MusicPlayerBackgroundTask extends BaseAudioHandler { + final _player = AudioPlayer( + audioLoadConfiguration: AudioLoadConfiguration( + androidLoadControl: AndroidLoadControl( + minBufferDuration: FinampSettingsHelper.finampSettings.bufferDuration, + maxBufferDuration: FinampSettingsHelper.finampSettings.bufferDuration, + prioritizeTimeOverSizeThresholds: true, + ), + darwinLoadControl: DarwinLoadControl( + preferredForwardBufferDuration: + FinampSettingsHelper.finampSettings.bufferDuration, + )), + ); + ConcatenatingAudioSource _queueAudioSource = ConcatenatingAudioSource( + children: [], + shuffleOrder: FinampShuffleOrder(), + ); + final _audioServiceBackgroundTaskLogger = Logger("MusicPlayerBackgroundTask"); + final _jellyfinApiHelper = GetIt.instance(); + final _offlineListenLogHelper = GetIt.instance(); + final _finampUserHelper = GetIt.instance(); + + /// Set when shuffle mode is changed. If true, [onUpdateQueue] will create a + /// shuffled [ConcatenatingAudioSource]. + bool shuffleNextQueue = false; + + /// Set when creating a new queue. Will be used to set the first index in a + /// new queue. + int? nextInitialIndex; + + /// Set to true when we're stopping the audio service. Used to avoid playback + /// progress reporting. + bool _isStopping = false; + + /// Holds the current sleep timer, if any. This is a ValueNotifier so that + /// widgets like SleepTimerButton can update when the sleep timer is/isn't + /// null. + bool _sleepTimerIsSet = false; + Duration _sleepTimerDuration = Duration.zero; + final ValueNotifier _sleepTimer = ValueNotifier(null); + + List? get shuffleIndices => _player.shuffleIndices; + + ValueListenable get sleepTimer => _sleepTimer; + + MusicPlayerBackgroundTask() { + _audioServiceBackgroundTaskLogger.info("Starting audio service"); + + // Propagate all events from the audio player to AudioService clients. + _player.playbackEventStream.listen((event) async { + final prevState = playbackState.valueOrNull; + final prevIndex = prevState?.queueIndex; + final prevItem = mediaItem.valueOrNull; + final currentState = _transformEvent(event); + final currentIndex = currentState.queueIndex; + + playbackState.add(currentState); + + if (currentIndex != null) { + final currentItem = _getQueueItem(currentIndex); + + // Differences in queue index or item id are considered track changes + if (currentIndex != prevIndex || currentItem.id != prevItem?.id) { + mediaItem.add(currentItem); + + onTrackChanged(currentItem, currentState, prevItem, prevState); + } + } + + if (playbackState.valueOrNull != null && + playbackState.valueOrNull?.processingState != + AudioProcessingState.idle && + playbackState.valueOrNull?.processingState != + AudioProcessingState.completed && + !FinampSettingsHelper.finampSettings.isOffline && + !_isStopping) { + await _updatePlaybackProgress(); + } + }); + + // Special processing for state transitions. + _player.processingStateStream.listen((event) { + if (event == ProcessingState.completed) { + stop(); + } + }); + + // PlaybackEvent doesn't include shuffle/loops so we listen for changes here + _player.shuffleModeEnabledStream.listen( + (_) => playbackState.add(_transformEvent(_player.playbackEvent))); + _player.loopModeStream.listen( + (_) => playbackState.add(_transformEvent(_player.playbackEvent))); + } + + @override + Future play() { + // If a sleep timer has been set and the timer went off + // causing play to pause, if the user starts to play + // audio again, and the sleep timer hasn't been explicitly + // turned off, then reset the sleep timer. + // This is useful if the sleep timer pauses play too early + // and the user wants to continue listening + if (_sleepTimerIsSet && _sleepTimer.value == null) { + // restart the sleep timer for another period + setSleepTimer(_sleepTimerDuration); + } + + return _player.play(); + } + + @override + Future pause() => _player.pause(); + + @override + Future stop() async { + try { + _audioServiceBackgroundTaskLogger.info("Stopping audio service"); + + _isStopping = true; + + // Tell Jellyfin we're no longer playing audio if we're online + if (!FinampSettingsHelper.finampSettings.isOffline) { + final playbackInfo = generateCurrentPlaybackProgressInfo(); + if (playbackInfo != null) { + await _jellyfinApiHelper.stopPlaybackProgress(playbackInfo); + } + } else { + final currentIndex = _player.currentIndex; + if (_queueAudioSource.length != 0 && currentIndex != null) { + final item = _getQueueItem(currentIndex); + _offlineListenLogHelper.logOfflineListen(item); + } + } + + // Stop playing audio. + await _player.stop(); + + // Seek to the start of the first item in the queue + await _player.seek(Duration.zero, index: 0); + + _sleepTimerIsSet = false; + _sleepTimerDuration = Duration.zero; + + _sleepTimer.value?.cancel(); + _sleepTimer.value = null; + + await super.stop(); + + // await _player.dispose(); + // await _eventSubscription?.cancel(); + // // It is important to wait for this state to be broadcast before we shut + // // down the task. If we don't, the background task will be destroyed before + // // the message gets sent to the UI. + // await _broadcastState(); + // // Shut down this background task + // await super.stop(); + + _isStopping = false; + } catch (e) { + _audioServiceBackgroundTaskLogger.severe(e); + return Future.error(e); + } + } + + @override + @Deprecated("Use addQueueItems instead") + Future addQueueItem(MediaItem mediaItem) async { + addQueueItems([mediaItem]); + } + + @override + Future addQueueItems(List mediaItems) async { + try { + final sources = + await Future.wait(mediaItems.map((i) => _mediaItemToAudioSource(i))); + await _queueAudioSource.addAll(sources); + queue.add(_queueFromSource()); + } catch (e) { + _audioServiceBackgroundTaskLogger.severe(e); + return Future.error(e); + } + } + + Future insertQueueItemsNext(List mediaItems) async { + try { + var idx = _player.currentIndex; + if (idx != null) { + if (_player.shuffleModeEnabled) { + var next = _player.shuffleIndices?.indexOf(idx); + idx = next == -1 || next == null ? null : next + 1; + } else { + ++idx; + } + } + idx ??= 0; + + final sources = + await Future.wait(mediaItems.map((i) => _mediaItemToAudioSource(i))); + await _queueAudioSource.insertAll(idx, sources); + queue.add(_queueFromSource()); + } catch (e) { + _audioServiceBackgroundTaskLogger.severe(e); + return Future.error(e); + } + } + + @override + Future updateQueue(List newQueue) async { + try { + // Convert the MediaItems to AudioSources + List audioSources = []; + for (final mediaItem in newQueue) { + audioSources.add(await _mediaItemToAudioSource(mediaItem)); + } + + // Create a new ConcatenatingAudioSource with the new queue. + _queueAudioSource = ConcatenatingAudioSource( + children: audioSources, + shuffleOrder: FinampShuffleOrder(), + ); + + try { + await _player.setAudioSource( + _queueAudioSource, + initialIndex: nextInitialIndex, + ); + } on PlayerException catch (e) { + _audioServiceBackgroundTaskLogger + .severe("Player error code ${e.code}: ${e.message}"); + } on PlayerInterruptedException catch (e) { + _audioServiceBackgroundTaskLogger + .warning("Player interrupted: ${e.message}"); + } catch (e) { + _audioServiceBackgroundTaskLogger + .severe("Player error ${e.toString()}"); + } + queue.add(_queueFromSource()); + + // Sets the media item for the new queue. This will be whatever is + // currently playing from the new queue (for example, the first song in + // an album). If the player is shuffling, set the index to the player's + // current index. Otherwise, set it to nextInitialIndex. nextInitialIndex + // is much more stable than the current index as we know the value is set + // when running this function. + if (_player.shuffleModeEnabled) { + if (_player.currentIndex == null) { + _audioServiceBackgroundTaskLogger.severe( + "_player.currentIndex is null during onUpdateQueue, not setting new media item"); + } else { + mediaItem.add(_getQueueItem(_player.currentIndex!)); + } + } else { + if (nextInitialIndex == null) { + _audioServiceBackgroundTaskLogger.severe( + "nextInitialIndex is null during onUpdateQueue, not setting new media item"); + } else { + mediaItem.add(_getQueueItem(nextInitialIndex!)); + } + } + + shuffleNextQueue = false; + nextInitialIndex = null; + } catch (e) { + _audioServiceBackgroundTaskLogger.severe(e); + return Future.error(e); + } + } + + @override + Future skipToPrevious() async { + try { + if (!_player.hasPrevious || _player.position.inSeconds >= 5) { + await _player.seek(Duration.zero, index: _player.currentIndex); + } else { + await _player.seek(Duration.zero, index: _player.previousIndex); + } + } catch (e) { + _audioServiceBackgroundTaskLogger.severe(e); + return Future.error(e); + } + } + + @override + Future skipToNext() async { + try { + await _player.seekToNext(); + } catch (e) { + _audioServiceBackgroundTaskLogger.severe(e); + return Future.error(e); + } + } + + Future skipToIndex(int index) async { + try { + await _player.seek(Duration.zero, index: index); + } catch (e) { + _audioServiceBackgroundTaskLogger.severe(e); + return Future.error(e); + } + } + + @override + Future seek(Duration position) async { + try { + await _player.seek(position); + } catch (e) { + _audioServiceBackgroundTaskLogger.severe(e); + return Future.error(e); + } + } + + @override + Future setShuffleMode(AudioServiceShuffleMode shuffleMode) async { + try { + switch (shuffleMode) { + case AudioServiceShuffleMode.all: + await _player.setShuffleModeEnabled(true); + shuffleNextQueue = true; + break; + case AudioServiceShuffleMode.none: + await _player.setShuffleModeEnabled(false); + shuffleNextQueue = false; + break; + default: + return Future.error( + "Unsupported AudioServiceRepeatMode! Recieved ${shuffleMode.toString()}, requires all or none."); + } + } catch (e) { + _audioServiceBackgroundTaskLogger.severe(e); + return Future.error(e); + } + } + + @override + Future setRepeatMode(AudioServiceRepeatMode repeatMode) async { + try { + switch (repeatMode) { + case AudioServiceRepeatMode.all: + await _player.setLoopMode(LoopMode.all); + break; + case AudioServiceRepeatMode.none: + await _player.setLoopMode(LoopMode.off); + break; + case AudioServiceRepeatMode.one: + await _player.setLoopMode(LoopMode.one); + break; + default: + return Future.error( + "Unsupported AudioServiceRepeatMode! Received ${repeatMode.toString()}, requires all, none, or one.", + ); + } + } catch (e) { + _audioServiceBackgroundTaskLogger.severe(e); + return Future.error(e); + } + } + + @override + Future removeQueueItemAt(int index) async { + try { + await _queueAudioSource.removeAt(index); + queue.add(_queueFromSource()); + } catch (e) { + _audioServiceBackgroundTaskLogger.severe(e); + return Future.error(e); + } + } + + /// Report track changes to the Jellyfin Server if the user is not offline. + Future onTrackChanged( + MediaItem currentItem, + PlaybackState currentState, + MediaItem? previousItem, + PlaybackState? previousState, + ) async { + final isOffline = FinampSettingsHelper.finampSettings.isOffline; + + if (previousItem != null && + previousState != null && + // don't submit stop events for idle tracks (at position 0 and not playing) + (previousState.playing || + previousState.updatePosition != Duration.zero)) { + if (!isOffline) { + final playbackData = generatePlaybackProgressInfoFromState( + previousItem, + previousState, + ); + + if (playbackData != null) { + try { + await _jellyfinApiHelper.stopPlaybackProgress(playbackData); + } catch (e) { + _offlineListenLogHelper.logOfflineListen(previousItem); + } + } + } else { + _offlineListenLogHelper.logOfflineListen(previousItem); + } + } + + if (!isOffline) { + final playbackData = generatePlaybackProgressInfoFromState( + currentItem, + currentState, + ); + + if (playbackData != null) { + await _jellyfinApiHelper.reportPlaybackStart(playbackData); + } + } + } + + /// Generates PlaybackProgressInfo for the supplied item and player info. + PlaybackProgressInfo? generatePlaybackProgressInfo( + MediaItem item, { + required bool isPaused, + required bool isMuted, + required Duration playerPosition, + required String repeatMode, + required bool includeNowPlayingQueue, + }) { + try { + return PlaybackProgressInfo( + itemId: item.extras!["itemJson"]["Id"], + isPaused: isPaused, + isMuted: isMuted, + positionTicks: playerPosition.inMicroseconds * 10, + repeatMode: repeatMode, + playMethod: item.extras!["shouldTranscode"] ?? false + ? "Transcode" + : "DirectPlay", + // We don't send the queue since it seems useless and it can cause + // issues with large queues. + // https://github.com/jmshrv/finamp/issues/387 + + // nowPlayingQueue: includeNowPlayingQueue + // ? _queueFromSource() + // .map( + // (e) => QueueItem( + // id: e.extras!["itemJson"]["Id"], playlistItemId: e.id), + // ) + // .toList() + // : null, + ); + } catch (e) { + _audioServiceBackgroundTaskLogger.severe(e); + rethrow; + } + } + + /// Generates PlaybackProgressInfo from current player info. + /// Returns null if _queue is empty. + /// If an item is not supplied, the current queue index will be used. + PlaybackProgressInfo? generateCurrentPlaybackProgressInfo() { + final currentIndex = _player.currentIndex; + if (_queueAudioSource.length == 0 || currentIndex == null) { + // This function relies on _queue having items, + // so we return null if it's empty or no index is played + // and no custom item was passed to avoid more errors. + return null; + } + final item = _getQueueItem(currentIndex); + + return generatePlaybackProgressInfo( + item, + isPaused: !_player.playing, + isMuted: _player.volume == 0, + playerPosition: _player.position, + repeatMode: _jellyfinRepeatModeFromLoopMode(_player.loopMode), + includeNowPlayingQueue: false, + ); + } + + /// Generates PlaybackProgressInfo for the supplied item and playback state. + PlaybackProgressInfo? generatePlaybackProgressInfoFromState( + MediaItem item, + PlaybackState state, + ) { + final duration = item.duration; + return generatePlaybackProgressInfo( + item, + isPaused: !state.playing, + // always consider as unmuted + isMuted: false, + // ensure the (extrapolated) position doesn't exceed the duration + playerPosition: duration != null && state.position > duration + ? duration + : state.position, + repeatMode: _jellyfinRepeatModeFromRepeatMode(state.repeatMode), + includeNowPlayingQueue: true, + ); + } + + void setNextInitialIndex(int index) { + nextInitialIndex = index; + } + + Future reorderQueue(int oldIndex, int newIndex) async { + // When we're moving an item forwards, we need to reduce newIndex by 1 + // to account for the current item being removed before re-insertion. + if (oldIndex < newIndex) { + newIndex -= 1; + } + await _queueAudioSource.move(oldIndex, newIndex); + queue.add(_queueFromSource()); + _audioServiceBackgroundTaskLogger.log(Level.INFO, "Published queue"); + } + + /// Sets the sleep timer with the given [duration]. + Timer setSleepTimer(Duration duration) { + _sleepTimerIsSet = true; + _sleepTimerDuration = duration; + + _sleepTimer.value = Timer(duration, () async { + _sleepTimer.value = null; + return await pause(); + }); + return _sleepTimer.value!; + } + + /// Cancels the sleep timer and clears it. + void clearSleepTimer() { + _sleepTimerIsSet = false; + _sleepTimerDuration = Duration.zero; + + _sleepTimer.value?.cancel(); + _sleepTimer.value = null; + } + + /// Transform a just_audio event into an audio_service state. + /// + /// This method is used from the constructor. Every event received from the + /// just_audio player will be transformed into an audio_service state so that + /// it can be broadcast to audio_service clients. + PlaybackState _transformEvent(PlaybackEvent event) { + return PlaybackState( + controls: [ + MediaControl.skipToPrevious, + if (_player.playing) MediaControl.pause else MediaControl.play, + MediaControl.stop, + MediaControl.skipToNext, + ], + systemActions: const { + MediaAction.seek, + MediaAction.seekForward, + MediaAction.seekBackward, + }, + androidCompactActionIndices: const [0, 1, 3], + processingState: const { + ProcessingState.idle: AudioProcessingState.idle, + ProcessingState.loading: AudioProcessingState.loading, + ProcessingState.buffering: AudioProcessingState.buffering, + ProcessingState.ready: AudioProcessingState.ready, + ProcessingState.completed: AudioProcessingState.completed, + }[_player.processingState]!, + playing: _player.playing, + updatePosition: _player.position, + bufferedPosition: _player.bufferedPosition, + speed: _player.speed, + queueIndex: event.currentIndex, + shuffleMode: _player.shuffleModeEnabled + ? AudioServiceShuffleMode.all + : AudioServiceShuffleMode.none, + repeatMode: _audioServiceRepeatMode(_player.loopMode), + ); + } + + Future _updatePlaybackProgress() async { + try { + JellyfinApiHelper jellyfinApiHelper = GetIt.instance(); + + final playbackInfo = generateCurrentPlaybackProgressInfo(); + if (playbackInfo != null) { + await jellyfinApiHelper.updatePlaybackProgress(playbackInfo); + } + } catch (e) { + _audioServiceBackgroundTaskLogger.severe(e); + return Future.error(e); + } + } + + MediaItem _getQueueItem(int index) { + return _queueAudioSource.sequence[index].tag as MediaItem; + } + + List _queueFromSource() { + return _queueAudioSource.sequence.map((e) => e.tag as MediaItem).toList(); + } + + /// Syncs the list of MediaItems (_queue) with the internal queue of the player. + /// Called by onAddQueueItem and onUpdateQueue. + Future _mediaItemToAudioSource(MediaItem mediaItem) async { + if (mediaItem.extras!["downloadedSongJson"] == null) { + // If DownloadedSong wasn't passed, we assume that the item is not + // downloaded. + + // If offline, we throw an error so that we don't accidentally stream from + // the internet. See the big comment in _songUri() to see why this was + // passed in extras. + if (mediaItem.extras!["isOffline"]) { + return Future.error( + "Offline mode enabled but downloaded song not found."); + } else { + if (mediaItem.extras!["shouldTranscode"] == true) { + return HlsAudioSource(await _songUri(mediaItem), tag: mediaItem); + } else { + return AudioSource.uri(await _songUri(mediaItem), tag: mediaItem); + } + } + } else { + // We have to deserialize this because Dart is stupid and can't handle + // sending classes through isolates. + final downloadedSong = + DownloadedSong.fromJson(mediaItem.extras!["downloadedSongJson"]); + + // Path verification and stuff is done in AudioServiceHelper, so this path + // should be valid. + final downloadUri = Uri.file(downloadedSong.file.path); + return AudioSource.uri(downloadUri, tag: mediaItem); + } + } + + Future _songUri(MediaItem mediaItem) async { + // We need the platform to be Android or iOS to get device info + assert(Platform.isAndroid || Platform.isIOS, + "_songUri() only supports Android and iOS"); + + // When creating the MediaItem (usually in AudioServiceHelper), we specify + // whether or not to transcode. We used to pull from FinampSettings here, + // but since audio_service runs in an isolate (or at least, it does until + // 0.18), the value would be wrong if changed while a song was playing since + // Hive is bad at multi-isolate stuff. + + final androidId = + Platform.isAndroid ? await const AndroidId().getId() : null; + final iosDeviceInfo = + Platform.isIOS ? await DeviceInfoPlugin().iosInfo : null; + + final parsedBaseUrl = Uri.parse(_finampUserHelper.currentUser!.baseUrl); + + List builtPath = List.from(parsedBaseUrl.pathSegments); + + Map queryParameters = + Map.from(parsedBaseUrl.queryParameters); + + // We include the user token as a query parameter because just_audio used to + // have issues with headers in HLS, and this solution still works fine + queryParameters["ApiKey"] = _finampUserHelper.currentUser!.accessToken; + + if (mediaItem.extras!["shouldTranscode"]) { + builtPath.addAll([ + "Audio", + mediaItem.extras!["itemJson"]["Id"], + "main.m3u8", + ]); + + queryParameters.addAll({ + "audioCodec": "aac", + // Ideally we'd use 48kHz when the source is, realistically it doesn't + // matter too much + "audioSampleRate": "44100", + "maxAudioBitDepth": "16", + "audioBitRate": + FinampSettingsHelper.finampSettings.transcodeBitrate.toString(), + }); + } else { + builtPath.addAll([ + "Items", + mediaItem.extras!["itemJson"]["Id"], + "File", + ]); + } + + return Uri( + host: parsedBaseUrl.host, + port: parsedBaseUrl.port, + scheme: parsedBaseUrl.scheme, + userInfo: parsedBaseUrl.userInfo, + pathSegments: builtPath, + queryParameters: queryParameters, + ); + } +} + +AudioServiceRepeatMode _audioServiceRepeatMode(LoopMode loopMode) { + switch (loopMode) { + case LoopMode.off: + return AudioServiceRepeatMode.none; + case LoopMode.one: + return AudioServiceRepeatMode.one; + case LoopMode.all: + return AudioServiceRepeatMode.all; + } +} + +String _jellyfinRepeatModeFromLoopMode(LoopMode loopMode) { + switch (loopMode) { + case LoopMode.off: + return "RepeatNone"; + case LoopMode.one: + return "RepeatOne"; + case LoopMode.all: + return "RepeatAll"; + } +} + +String _jellyfinRepeatModeFromRepeatMode(AudioServiceRepeatMode repeatMode) { + switch (repeatMode) { + case AudioServiceRepeatMode.none: + return "RepeatNone"; + case AudioServiceRepeatMode.one: + return "RepeatOne"; + case AudioServiceRepeatMode.all: + case AudioServiceRepeatMode.group: + return "RepeatAll"; + } +} \ No newline at end of file diff --git a/lib/services/offline_listen_helper.dart b/lib/services/offline_listen_helper.dart new file mode 100644 index 0000000..0affc86 --- /dev/null +++ b/lib/services/offline_listen_helper.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:audio_service/audio_service.dart'; +import 'package:finamp/models/finamp_models.dart'; +import 'package:finamp/services/finamp_user_helper.dart'; +import 'package:get_it/get_it.dart'; +import 'package:hive/hive.dart'; +import 'package:logging/logging.dart'; +import 'package:path_provider/path_provider.dart'; + +/// Logs offline listens or failed submissions to a file. +class OfflineListenLogHelper { + final _logger = Logger("OfflineListenLogHelper"); + final _finampUserHelper = GetIt.instance(); + + Future get _logDirectory async { + if (!Platform.isAndroid) { + return await getApplicationDocumentsDirectory(); + } + + final List? dirs = + await getExternalStorageDirectories(type: StorageDirectory.documents); + return dirs?.first ?? await getApplicationDocumentsDirectory(); + } + + Future get _logFile async { + final Directory directory = await _logDirectory; + return File("${directory.path}/listens.json"); + } + + /// Logs a listen to a file. + /// + /// This is used when the user is offline or submitting live playback events fails. + Future logOfflineListen(MediaItem item) async { + final itemJson = item.extras!["itemJson"]; + + final offlineListen = OfflineListen( + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + userId: _finampUserHelper.currentUserId!, + itemId: itemJson["Id"], + name: itemJson["Name"], + artist: itemJson["AlbumArtist"], + album: itemJson["Album"], + trackMbid: itemJson["ProviderIds"]?["MusicBrainzTrack"], + ); + + await _logOfflineListen(offlineListen); + } + + /// Logs a listen to a file. + /// + /// This is used when the user is offline or submitting live playback events fails. + /// The [timestamp] provided to this function should be in seconds + /// and marks the time the track was stopped. + Future _logOfflineListen(OfflineListen listen) async { + Hive.box("OfflineListens").add(listen); + + _exportOfflineListenToFile(listen); + } + + Future _exportOfflineListenToFile(OfflineListen listen) async { + final data = { + 'timestamp': listen.timestamp, + 'item_id': listen.itemId, + 'title': listen.name, + 'artist': listen.artist, + 'album': listen.album, + 'track_mbid': listen.trackMbid, + 'user_id': listen.userId, + }; + final content = json.encode(data) + Platform.lineTerminator; + + final file = await _logFile; + try { + file.writeAsString(content, mode: FileMode.append, flush: true); + } catch (e) { + _logger.warning("Failed to write listen to file: $content"); + } + } +} \ No newline at end of file diff --git a/lib/services/process_artist.dart b/lib/services/process_artist.dart new file mode 100644 index 0000000..b1fdebc --- /dev/null +++ b/lib/services/process_artist.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +String processArtist(String? artist, BuildContext context) { + if (artist == null) { + return AppLocalizations.of(context)!.unknownArtist; + } else { + return artist; + } +} diff --git a/lib/services/progress_state_stream.dart b/lib/services/progress_state_stream.dart new file mode 100644 index 0000000..a10284a --- /dev/null +++ b/lib/services/progress_state_stream.dart @@ -0,0 +1,25 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:get_it/get_it.dart'; +import 'package:rxdart/rxdart.dart'; + +import 'music_player_background_task.dart'; + +class ProgressState { + final MediaItem? mediaItem; + final PlaybackState playbackState; + final Duration position; + + ProgressState(this.mediaItem, this.playbackState, this.position); +} + +/// Encapsulate all the different data we're interested in into a single +/// stream so we don't have to nest StreamBuilders. +Stream get progressStateStream { + final audioHandler = GetIt.instance(); + return Rx.combineLatest3( + audioHandler.mediaItem, + audioHandler.playbackState, + AudioService.position.startWith(audioHandler.playbackState.value.position), + (mediaItem, playbackState, position) => + ProgressState(mediaItem, playbackState, position)); +} diff --git a/lib/services/sync_helper.dart b/lib/services/sync_helper.dart new file mode 100644 index 0000000..5f597b7 --- /dev/null +++ b/lib/services/sync_helper.dart @@ -0,0 +1,148 @@ +import 'dart:collection'; + +import 'package:finamp/components/AlbumScreen/download_dialog.dart'; +import 'package:finamp/services/finamp_settings_helper.dart'; +import 'package:finamp/services/finamp_user_helper.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:get_it/get_it.dart'; +import 'package:finamp/services/downloads_helper.dart'; +import 'package:finamp/services/jellyfin_api_helper.dart'; +import 'package:logging/logging.dart'; + +import '../models/finamp_models.dart'; +import '../models/jellyfin_models.dart'; + +class SyncState { + final Set toAdd; + final Set toRemove; + final Set toUpdate; + final List addItemCache; + + SyncState(this.toAdd, this.toRemove, this.toUpdate, this.addItemCache); +} + +class DownloadsSyncHelper { + final DownloadsHelper downloadsHelper = GetIt.instance(); + final JellyfinApiHelper jellyfinApiHelper = + GetIt.instance(); + final FinampUserHelper _finampUserHelper = GetIt.instance(); + final Logger logger; + + DownloadsSyncHelper(this.logger); + + void sync(BuildContext context, BaseItemDto parentObject, + List items) async { + var syncState = _getSyncState(parentObject.id, items); + + logger.info("Items to be added ${syncState.toAdd}"); + logger.info("Items to be removed ${syncState.toRemove}"); + logger.info("Items with no change ${syncState.toUpdate}"); + + await _removeDownloadedItems(parentObject.id, syncState.toRemove); + await _downloadItems(context, parentObject, syncState); + } + + Future _downloadItems(BuildContext context, BaseItemDto parentObject, + SyncState syncState) async { + final List itemsToDownload = []; + + // we include update items in case any items have been orphaned + for (String itemToAdd in {...syncState.toAdd, ...syncState.toUpdate}) { + final cacheIndex = syncState.addItemCache + .indexWhere((element) => element.id == itemToAdd); + BaseItemDto? item; + if (cacheIndex != -1) { + item = syncState.addItemCache.removeAt(cacheIndex); + } + if (item != null) { + itemsToDownload.add(item); + } + } + + final selectedDownloadLocation = FinampSettingsHelper + .finampSettings.downloadLocationsMap.values.length > + 1 + ? await showDialog( + context: context, + builder: (context) => DownloadDialog( + parents: [parentObject], + // getItems returns null so we have to null check + // each element + items: [itemsToDownload], + viewId: _finampUserHelper.currentUser!.currentViewId!, + ), + ) + : FinampSettingsHelper.finampSettings.downloadLocationsMap.values.first; + + if (itemsToDownload.isNotEmpty && selectedDownloadLocation != null) { + await downloadsHelper.addDownloads( + items: itemsToDownload, + parent: parentObject, + useHumanReadableNames: selectedDownloadLocation.useHumanReadableNames, + downloadLocation: selectedDownloadLocation, + viewId: _finampUserHelper.currentUser!.currentViewId!, + ); + } else { + logger.warning("No items to download for parent: ${parentObject.id}"); + } + } + + Future _removeDownloadedItems( + String playlistParentId, Set idsToRemove) async { + var itemsToRemove = idsToRemove.toList(); + await downloadsHelper.deleteDownloadChildren( + jellyfinItemIds: itemsToRemove, + deletedFor: itemsToRemove.isEmpty ? null : playlistParentId); + + final downloadParent = + downloadsHelper.getDownloadedParent(playlistParentId); + if (downloadParent != null && downloadParent.downloadedChildren.isEmpty) { + // only remove parent if there are no children left + await downloadsHelper.deleteDownloadParent(deletedFor: playlistParentId); + } + await downloadsHelper.removeChildFromParent( + parentId: playlistParentId, childIds: idsToRemove.toList()); + } + + SyncState _getSyncState( + String playlistParentId, List existingPlaylistItems) { + List downloadedSongs = + downloadsHelper.downloadedItems.toList(); + List downloadedSongsCache = []; + List playlistItems = []; + for (BaseItemDto item in existingPlaylistItems) { + // songs actively in playlist + logger.info("Song in playlist id ${item.id} name ${item.name}"); + // playlistItems.putIfAbsent(item.id, () => item); + if (playlistItems.indexWhere((element) => element.id == item.id) == -1) { + playlistItems.add(item); + } + } + + for (DownloadedSong downloadedSong in downloadedSongs) { + if (downloadedSong.mediaSourceInfo.id != null && + downloadedSong.requiredBy.contains(playlistParentId)) { + // songs actively downloaded + logger.info( + "Downloaded song playlist id ${downloadedSong.mediaSourceInfo.id} name ${downloadedSong.mediaSourceInfo.name} requiredBy ${downloadedSong.requiredBy.toString()}"); + // downloadedSongsCache.putIfAbsent(downloadedSong.mediaSourceInfo.id!, () => downloadedSong); + if (downloadedSongsCache.indexWhere((element) => + element.mediaSourceInfo.id == + downloadedSong.mediaSourceInfo.id) == + -1) { + downloadedSongsCache.add(downloadedSong); + } + } + } + + Set playlistIds = playlistItems.map((e) => e.id).toSet(); + Set downloadedIds = + downloadedSongsCache.map((e) => e.mediaSourceInfo.id!).toSet(); + return SyncState( + playlistIds.difference(downloadedIds), + downloadedIds.difference(playlistIds), + playlistIds.intersection(downloadedIds), + playlistItems); + } +} diff --git a/lib/services/theme_mode_helper.dart b/lib/services/theme_mode_helper.dart new file mode 100644 index 0000000..40396fd --- /dev/null +++ b/lib/services/theme_mode_helper.dart @@ -0,0 +1,15 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +/// A helper for setting the theme, like FinampSettingsHelper. We don't do theme +/// stuff in FinampSettingsHelper as we would have to rebuild the MaterialApp on +/// every setting change. +class ThemeModeHelper { + static ValueListenable> get themeModeListener => + Hive.box("ThemeMode").listenable(keys: ["ThemeMode"]); + + static void setThemeMode(ThemeMode themeMode) { + Hive.box("ThemeMode").put("ThemeMode", themeMode); + } +} diff --git a/lib/setup_logging.dart b/lib/setup_logging.dart new file mode 100644 index 0000000..10e43b3 --- /dev/null +++ b/lib/setup_logging.dart @@ -0,0 +1,21 @@ +import 'package:flutter/foundation.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logging/logging.dart'; + +import 'services/finamp_logs_helper.dart'; + +void setupLogging() { + GetIt.instance.registerSingleton(FinampLogsHelper()); + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((event) { + final finampLogsHelper = GetIt.instance(); + + // We don't want to print log messages from the Flutter logger since Flutter prints logs by itself + if (kDebugMode && event.loggerName != "Flutter") { + // ignore: avoid_print + print( + "[${event.loggerName}/${event.level.name}] ${event.time}: ${event.message}"); + } + finampLogsHelper.addLog(event); + }); +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..449e621 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1318 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" + url: "https://pub.dev" + source: hosted + version: "76.0.0" + _macros: + dependency: transitive + description: dart + source: sdk + version: "0.3.3" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" + url: "https://pub.dev" + source: hosted + version: "6.11.0" + android_id: + dependency: "direct main" + description: + name: android_id + sha256: "748ba5f93dd5c497e675d8eaa1404346ce4d1794464ea654576ff192d153b92a" + url: "https://pub.dev" + source: hosted + version: "0.4.0" + archive: + dependency: transitive + description: + name: archive + sha256: "6199c74e3db4fbfbd04f66d739e72fe11c8a8957d5f219f1f4482dbde6420b5a" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + args: + dependency: transitive + description: + name: args + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + url: "https://pub.dev" + source: hosted + version: "2.6.0" + async: + dependency: transitive + description: + name: async + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + url: "https://pub.dev" + source: hosted + version: "2.12.0" + audio_service: + dependency: "direct main" + description: + name: audio_service + sha256: f6c8191bef6b843da34675dd0731ad11d06094c36b691ffcf3148a4feb2e585f + url: "https://pub.dev" + source: hosted + version: "0.18.16" + audio_service_platform_interface: + dependency: transitive + description: + name: audio_service_platform_interface + sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + audio_service_web: + dependency: transitive + description: + name: audio_service_web + sha256: "4cdc2127cd4562b957fb49227dc58e3303fafb09bde2573bc8241b938cf759d9" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + audio_session: + dependency: "direct main" + description: + name: audio_session + sha256: b2a26ba8b7efa1790d6460e82971fde3e398cfbe2295df9dea22f3499d2c12a7 + url: "https://pub.dev" + source: hosted + version: "0.1.23" + auto_size_text: + dependency: "direct main" + description: + name: auto_size_text + sha256: "3f5261cd3fb5f2a9ab4e2fc3fba84fd9fcaac8821f20a1d4e71f557521b22599" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "294a2edaf4814a378725bfe6358210196f5ea37af89ecd81bfa32960113d4948" + url: "https://pub.dev" + source: hosted + version: "4.0.3" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: "99d3980049739a985cf9b21f30881f46db3ebc62c5b8d5e60e27440876b1ba1e" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "74691599a5bc750dc96a6b4bfd48f7d9d66453eab04c7f4063134800d6a5c573" + url: "https://pub.dev" + source: hosted + version: "2.4.14" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" + url: "https://pub.dev" + source: hosted + version: "8.0.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "28a712df2576b63c6c005c465989a348604960c0958d28be5303ba9baa841ac2" + url: "https://pub.dev" + source: hosted + version: "8.9.3" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + url: "https://pub.dev" + source: hosted + version: "2.0.3" + chopper: + dependency: "direct main" + description: + name: chopper + sha256: "1b6280ec22841b844448bec8ef2644d9cbe9ea8dfce13ec9cab9e8d3aac3830d" + url: "https://pub.dev" + source: hosted + version: "7.4.0" + chopper_generator: + dependency: "direct dev" + description: + name: chopper_generator + sha256: "2984ed8589132aa8fd8225482038cad2bd576405e3751aa237d466870f5dad42" + url: "https://pub.dev" + source: hosted + version: "7.4.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clipboard: + dependency: "direct main" + description: + name: clipboard + sha256: "2ec38f0e59878008ceca0ab122e4bfde98847f88ef0f83331362ba4521f565a9" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + url: "https://pub.dev" + source: hosted + version: "4.10.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" + url: "https://pub.dev" + source: hosted + version: "2.3.7" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: "4fa68e53e26ab17b70ca39f072c285562cfc1589df5bb1e9295db90f6645f431" + url: "https://pub.dev" + source: hosted + version: "11.2.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: "0b04e02b30791224b31969eb1b50d723498f402971bff3630bca2ba839bd1ed2" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + equatable: + dependency: transitive + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + url: "https://pub.dev" + source: hosted + version: "1.3.2" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: c904b4ab56d53385563c7c39d8e9fa9af086f91495dfc48717ad84a42c3cf204 + url: "https://pub.dev" + source: hosted + version: "8.1.7" + file_sizes: + dependency: "direct main" + description: + name: file_sizes + sha256: d24964a4b194b6116d490005428d07cb3e83834ad1f7ec6a1012dedc2f6d2a19 + url: "https://pub.dev" + source: hosted + version: "1.0.6" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + flutter_downloader: + dependency: "direct main" + description: + path: "." + ref: db66f36ec48dc50117e510e2f37995cc17b44534 + resolved-ref: db66f36ec48dc50117e510e2f37995cc17b44534 + url: "https://github.com/jmshrv/flutter_downloader.git" + source: git + version: "1.10.4" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "31cd0885738e87c72d6f055564d37fabcdacee743b396b78c7636c169cac64f5" + url: "https://pub.dev" + source: hosted + version: "0.14.2" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "615a505aef59b151b46bbeef55b36ce2b6ed299d160c51d84281946f0aa0ce0e" + url: "https://pub.dev" + source: hosted + version: "2.0.24" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_staggered_grid_view: + dependency: transitive + description: + name: flutter_staggered_grid_view + sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + flutter_sticky_header: + dependency: "direct main" + description: + name: flutter_sticky_header + sha256: "7f76d24d119424ca0c95c146b8627a457e8de8169b0d584f766c2c545db8f8be" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + get_it: + dependency: "direct main" + description: + name: get_it + sha256: f126a3e286b7f5b578bf436d5592968706c4c1de28a228b870ce375d9f743103 + url: "https://pub.dev" + source: hosted + version: "8.0.3" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + hive: + dependency: "direct main" + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + hive_flutter: + dependency: "direct main" + description: + name: hive_flutter + sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc + url: "https://pub.dev" + source: hosted + version: "1.1.0" + hive_generator: + dependency: "direct dev" + description: + name: hive_generator + sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + http: + dependency: transitive + description: + name: http + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "8346ad4b5173924b5ddddab782fc7d8a6300178c8b1dc427775405a01701c4a6" + url: "https://pub.dev" + source: hosted + version: "4.5.2" + infinite_scroll_pagination: + dependency: "direct main" + description: + name: infinite_scroll_pagination + sha256: "4047eb8191e8b33573690922a9e995af64c3949dc87efc844f936b039ea279df" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + url: "https://pub.dev" + source: hosted + version: "0.7.1" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: c2fcb3920cf2b6ae6845954186420fca40bc0a8abcc84903b7801f17d7050d7c + url: "https://pub.dev" + source: hosted + version: "6.9.0" + just_audio: + dependency: "direct main" + description: + name: just_audio + sha256: a49e7120b95600bd357f37a2bb04cd1e88252f7cdea8f3368803779b925b1049 + url: "https://pub.dev" + source: hosted + version: "0.9.42" + just_audio_platform_interface: + dependency: transitive + description: + name: just_audio_platform_interface + sha256: "0243828cce503c8366cc2090cefb2b3c871aa8ed2f520670d76fd47aa1ab2790" + url: "https://pub.dev" + source: hosted + version: "4.3.0" + just_audio_web: + dependency: transitive + description: + name: just_audio_web + sha256: "9a98035b8b24b40749507687520ec5ab404e291d2b0937823ff45d92cb18d448" + url: "https://pub.dev" + source: hosted + version: "0.4.13" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + url: "https://pub.dev" + source: hosted + version: "10.0.8" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + url: "https://pub.dev" + source: hosted + version: "3.0.9" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + locale_names: + dependency: "direct main" + description: + path: "." + ref: cea057c220f4ee7e09e8f1fc7036110245770948 + resolved-ref: cea057c220f4ee7e09e8f1fc7036110245770948 + url: "https://github.com/lamarios/locale_names.git" + source: git + version: "0.0.1" + logging: + dependency: "direct main" + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + macros: + dependency: transitive + description: + name: macros + sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" + url: "https://pub.dev" + source: hosted + version: "0.1.3-main.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mini_music_visualizer: + dependency: "direct main" + description: + name: mini_music_visualizer + sha256: "779a957424ce9a09cc00989a8cf9b7541ec22316d9781a43e701afa6acacf274" + url: "https://pub.dev" + source: hosted + version: "1.1.4" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + octo_image: + dependency: "direct main" + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "70c421fe9d9cc1a9a7f3b05ae56befd469fe4f8daa3b484823141a55442d858d" + url: "https://pub.dev" + source: hosted + version: "8.1.2" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: a5ef9986efc7bf772f2696183a3992615baa76c1ffb1189318dd8803778fb05b + url: "https://pub.dev" + source: hosted + version: "3.0.2" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" + url: "https://pub.dev" + source: hosted + version: "2.2.15" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb" + url: "https://pub.dev" + source: hosted + version: "11.3.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "71bbecfee799e65aff7c744761a57e817e73b738fedf62ab7afd5593da21f9f1" + url: "https://pub.dev" + source: hosted + version: "12.0.13" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: e6f6d73b12438ef13e648c4ae56bd106ec60d17e90a59c4545db6781229082a0 + url: "https://pub.dev" + source: hosted + version: "9.4.5" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: e9c8eadee926c4532d0305dff94b85bf961f16759c3af791486613152af4b4f9 + url: "https://pub.dev" + source: hosted + version: "4.2.3" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + posix: + dependency: transitive + description: + name: posix + sha256: a0117dc2167805aa9125b82eee515cc891819bac2f538c83646d355b16f58b9a + url: "https://pub.dev" + source: hosted + version: "6.0.1" + provider: + dependency: "direct main" + description: + name: provider + sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + url: "https://pub.dev" + source: hosted + version: "6.1.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + qs_dart: + dependency: transitive + description: + name: qs_dart + sha256: "56734fa99a8cc43d72b7396f26c0dad7d1a4ae4bfedc614e94e07ae7a833a179" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + recursive_regex: + dependency: transitive + description: + name: recursive_regex + sha256: f7252e3d3dfd1665e594d9fe035eca6bc54139b1f2fee38256fa427ea41adc60 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + rxdart: + dependency: "direct main" + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "6327c3f233729374d0abaafd61f6846115b2a481b4feddd8534211dc10659400" + url: "https://pub.dev" + source: hosted + version: "10.1.3" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b + url: "https://pub.dev" + source: hosted + version: "5.0.2" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + simple_gesture_detector: + dependency: "direct main" + description: + name: simple_gesture_detector + sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3 + url: "https://pub.dev" + source: hosted + version: "0.2.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + sliver_tools: + dependency: transitive + description: + name: sliver_tools + sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6 + url: "https://pub.dev" + source: hosted + version: "0.2.12" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" + url: "https://pub.dev" + source: hosted + version: "1.3.5" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709" + url: "https://pub.dev" + source: hosted + version: "2.5.4+6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "22adfd9a2c7d634041e96d6241e6e1c8138ca6817018afc5d443fef91dcefa9c" + url: "https://pub.dev" + source: hosted + version: "2.4.1+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" + url: "https://pub.dev" + source: hosted + version: "3.3.0+3" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + url: "https://pub.dev" + source: hosted + version: "0.7.4" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" + url: "https://pub.dev" + source: hosted + version: "6.3.14" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "16a513b6c12bb419304e72ea0ae2ab4fed569920d1c7cb850263fe3acc824626" + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" + url: "https://pub.dev" + source: hosted + version: "2.3.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" + value_layout_builder: + dependency: transitive + description: + name: value_layout_builder + sha256: c02511ea91ca5c643b514a33a38fa52536f74aa939ec367d02938b5ede6807fa + url: "https://pub.dev" + source: hosted + version: "0.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + url: "https://pub.dev" + source: hosted + version: "14.3.1" + watcher: + dependency: transitive + description: + name: watcher + sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + weak_map: + dependency: transitive + description: + name: weak_map + sha256: "5f8e5d5ce57dc624db5fae814dd689ccae1f17f92b426e52f0a7cbe7f6f4ab97" + url: "https://pub.dev" + source: hosted + version: "4.0.1" + web: + dependency: transitive + description: + name: web + sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + url: "https://pub.dev" + source: hosted + version: "0.1.6" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "154360849a56b7b67331c21f09a386562d88903f90a1099c5987afc1912e1f29" + url: "https://pub.dev" + source: hosted + version: "5.10.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" + url: "https://pub.dev" + source: hosted + version: "1.1.5" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.7.0-0 <4.0.0" + flutter: ">=3.24.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..36d2b62 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,137 @@ +name: finamp +description: A new Flutter project. + +# The following line prevents the package from being accidentally published to +# pub.dev using `pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +version: 0.6.27+52 + +environment: + sdk: ">=3.3.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + + json_annotation: ^4.9.0 + chopper: ^7.4.0 + get_it: ^8.0.3 + just_audio: ^0.9.42 + audio_service: ^0.18.15 + audio_session: ^0.1.21 + rxdart: ^0.28.0 + simple_gesture_detector: ^0.2.0 + flutter_downloader: + git: + url: https://github.com/jmshrv/flutter_downloader.git + ref: "db66f36ec48dc50117e510e2f37995cc17b44534" + path_provider: ^2.0.14 + hive: ^2.2.3 + hive_flutter: ^1.1.0 + file_sizes: ^1.0.6 + logging: ^1.1.1 + clipboard: ^0.1.3 + file_picker: ^8.1.7 + permission_handler: ^11.0.0 + provider: ^6.0.5 + uuid: ^4.5.1 + infinite_scroll_pagination: ^4.0.0 + flutter_sticky_header: ^0.7.0 + device_info_plus: ^11.2.0 + package_info_plus: ^8.1.2 + octo_image: ^2.0.0 + share_plus: ^10.1.3 + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.5 + path: ^1.8.2 + android_id: ^0.4.0 + intl: ^0.19.0 + auto_size_text: ^3.0.0 + flutter_riverpod: ^2.3.4 + # locale_names: + # git: + # url: https://github.com/Mantano/locale_names.git + # ref: 74e55fd + locale_names: + git: + url: https://github.com/lamarios/locale_names.git + ref: cea057c220f4ee7e09e8f1fc7036110245770948 + mini_music_visualizer: ^1.0.2 + url_launcher: ^6.2.5 + +dev_dependencies: + flutter_test: + sdk: flutter + build_runner: ^2.3.3 + chopper_generator: ^7.4.0 + hive_generator: ^2.0.0 + json_serializable: ^6.6.1 + flutter_launcher_icons: ^0.14.2 + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + assets: + - images/finamp.png + # - images/a_dot_ham.jpeg + + generate: true + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages + +flutter_icons: + android: true + ios: true + image_path: "assets/icon/icon_combined.png" + adaptive_icon_foreground: "assets/icon/icon_foreground.png" + adaptive_icon_background: "#000B25" + remove_alpha_ios: true diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..76d154b --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "dart-best-practices": { + "source": "kevmoo/dash_skills", + "sourceType": "github", + "skillPath": ".agent/skills/dart-best-practices/SKILL.md", + "computedHash": "1f529bd357368f67621de2c25531f69e16f726ecbe0071aabbf47b6d7b2a16c4" + } + } +}