Compare commits
145 Commits
discover_e
...
testing-fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdd14d8e6c | ||
|
|
67d7f586b2 | ||
|
|
7c22a8bb25 | ||
|
|
ff3864a09a | ||
|
|
4484b9e4ad | ||
|
|
e9554ffbcb | ||
|
|
ad3901d484 | ||
|
|
ecca4c3dc8 | ||
|
|
d05521f884 | ||
|
|
ff37c4bf81 | ||
|
|
84a5ad0b86 | ||
|
|
44ad11343a | ||
|
|
07e50e3cfe | ||
|
|
df8bbdb788 | ||
|
|
1ed381fe52 | ||
|
|
ddd1d5ac86 | ||
|
|
26845a502e | ||
|
|
21aac29020 | ||
|
|
c1ff02df14 | ||
|
|
e4927c4022 | ||
|
|
4fd797338b | ||
|
|
eca0e5943d | ||
|
|
56cc7309a5 | ||
|
|
b740d1af05 | ||
|
|
6d21b73367 | ||
|
|
a5704eef25 | ||
|
|
7e83682686 | ||
|
|
18d5aa61b0 | ||
|
|
7c2a719ba8 | ||
|
|
47313a74ff | ||
|
|
65a7a16298 | ||
|
|
9251e4f5b6 | ||
|
|
c4bc6abf83 | ||
|
|
3165289483 | ||
|
|
01aab41c25 | ||
|
|
1826258161 | ||
|
|
df5917060b | ||
|
|
b5aa05cc1b | ||
|
|
cd865992f2 | ||
|
|
370c0ab54a | ||
|
|
923f2484fb | ||
|
|
37928cd2c6 | ||
|
|
fc32ba97c1 | ||
|
|
e49084867e | ||
|
|
a046748ded | ||
|
|
047d708ef1 | ||
|
|
5b5f563d47 | ||
|
|
2b60ad3748 | ||
|
|
1f70043c83 | ||
|
|
7ce6f6a346 | ||
|
|
03814bff0c | ||
|
|
4c63a0ff13 | ||
|
|
93552fb872 | ||
|
|
1b61becdcf | ||
|
|
0499cad3c9 | ||
|
|
79752ef4b8 | ||
|
|
c1bd6d3fdb | ||
|
|
621423d9a4 | ||
|
|
edb11c89ba | ||
|
|
adb71fe09c | ||
|
|
c20cee2406 | ||
|
|
dcfad86c47 | ||
|
|
0a2bff67bf | ||
|
|
7aaa689cfb | ||
|
|
ad2a0ce897 | ||
|
|
d99615b24f | ||
|
|
09cc48ae55 | ||
|
|
6ab2223a80 | ||
|
|
6fd86162e0 | ||
|
|
707e8dbfcf | ||
|
|
5869bec781 | ||
|
|
e311a8bb32 | ||
|
|
547ccfceca | ||
|
|
3a1917949b | ||
|
|
3a1ce3258e | ||
|
|
13b2542bea | ||
|
|
6db3741a3b | ||
|
|
ce17eccd68 | ||
|
|
95dc683088 | ||
|
|
cf9d5f72f7 | ||
|
|
3096e1550a | ||
|
|
1b39435735 | ||
|
|
8f3d8505bb | ||
|
|
47e8aafe25 | ||
|
|
edf32d065e | ||
|
|
1fa6a0c3b9 | ||
|
|
2388989dd0 | ||
|
|
9e392277b1 | ||
|
|
4609c375db | ||
|
|
839c62ea72 | ||
|
|
dceef49f33 | ||
|
|
acbdc3111a | ||
|
|
98b91a6935 | ||
|
|
e1640e67d4 | ||
|
|
e875758419 | ||
|
|
214b120472 | ||
|
|
f139e0a098 | ||
|
|
e3c9a61887 | ||
|
|
0da3dc5084 | ||
|
|
a856a82249 | ||
|
|
fbdec00a62 | ||
|
|
6a7f980a0d | ||
|
|
10a855fe27 | ||
|
|
b4f8a2b27c | ||
|
|
89489b4d7c | ||
|
|
50296f8dfa | ||
|
|
f69cec864b | ||
|
|
73d5d33fc5 | ||
|
|
4d8ea12ddd | ||
|
|
7beb267ba7 | ||
|
|
7e13ef3537 | ||
|
|
47edca5bf5 | ||
|
|
925ba10b15 | ||
|
|
db2d0bb7e9 | ||
|
|
f3a2b2af0c | ||
|
|
967e88f88d | ||
|
|
b44734a493 | ||
|
|
6478b08a19 | ||
|
|
314e81565b | ||
|
|
f95e20d00f | ||
|
|
35a04d6e7e | ||
|
|
403264d2c9 | ||
|
|
6b06a4c388 | ||
|
|
678bce89b2 | ||
|
|
8b708228be | ||
|
|
42d31a73a3 | ||
|
|
946d2ae522 | ||
|
|
8e9eb50783 | ||
|
|
af3bc7757f | ||
|
|
eda1d05216 | ||
|
|
b58e0f8331 | ||
|
|
6dcf53650d | ||
|
|
bff53d9081 | ||
|
|
f3306e14c7 | ||
|
|
b5c075bac4 | ||
|
|
241d21c2aa | ||
|
|
789d77747c | ||
|
|
35050aa32f | ||
|
|
40e6bd9fae | ||
|
|
b317df2000 | ||
|
|
2e706228ee | ||
|
|
fe40185889 | ||
|
|
6bb4428a8a | ||
|
|
0881685915 | ||
|
|
3e13932d03 |
14
.github/workflows/mobile-lint.yml
vendored
14
.github/workflows/mobile-lint.yml
vendored
@@ -9,6 +9,7 @@ on:
|
||||
|
||||
env:
|
||||
FLUTTER_VERSION: "3.32.8"
|
||||
RUST_VERSION: "1.86.0"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -31,7 +32,18 @@ jobs:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
|
||||
|
||||
- run: flutter pub get
|
||||
|
||||
- name: Install Rust ${{ env.RUST_VERSION }}
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
|
||||
- name: Install Flutter Rust Bridge
|
||||
run: cargo install flutter_rust_bridge_codegen
|
||||
|
||||
- name: Generate Rust bindings
|
||||
run: flutter_rust_bridge_codegen generate
|
||||
|
||||
- run: flutter analyze --no-fatal-infos
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
## v1.7.15 (Unreleased)
|
||||
|
||||
- Custom domains.
|
||||
- Support Czech translations.
|
||||
- .
|
||||
|
||||
## v1.7.14
|
||||
|
||||
@@ -8,6 +8,12 @@ description: Guide to configuring Ente CLI for Self Hosted Instance
|
||||
If you are self-hosting, you can configure Ente CLI to export data & perform
|
||||
basic administrative actions.
|
||||
|
||||
::: tip Installing Ente CLI
|
||||
|
||||
For instructions on installing the Ente CLI, see the [README available on Github](https://github.com/ente-io/ente/tree/main/cli/README.md).
|
||||
|
||||
:::
|
||||
|
||||
## Step 1: Configure endpoint
|
||||
|
||||
To do this, first configure the CLI to use your server's endpoint.
|
||||
|
||||
@@ -22,8 +22,7 @@ can achieve this the following steps:
|
||||
# Change the DB name and DB user name if you use different
|
||||
# values.
|
||||
# If using Docker
|
||||
|
||||
docker exec -it <postgres-ente-container-name>
|
||||
docker exec -it <postgres-ente-container-name> sh
|
||||
psql -U pguser -d ente_db
|
||||
|
||||
# Or when using psql directly
|
||||
|
||||
@@ -46,7 +46,7 @@ If running Museum without Docker, the code should be visible in the terminal
|
||||
# Change the DB name and DB user name if you use different
|
||||
# values.
|
||||
|
||||
# If using Docker docker exec -it <postgres-ente-container-name>
|
||||
# If using Docker docker exec -it <postgres-ente-container-name> sh
|
||||
psql -U pguser -d ente_db
|
||||
|
||||
# Or when using psql directly
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
"importAegisGuide": "Použijte možnost \"Export the vault\" v nastavení aplikace Aegis.",
|
||||
"import2FasGuide": "Použijte možnost \"Settings->Backup -Export\" v 2FA.\n\nPokud je Vaše záloha šifrovaná, budete muset zadat heslo pro její odemčení",
|
||||
"importLastpassGuide": "V nastavení aplikace Lastpass Authenticator vyberte možnost \"Transfer accounts\" a poté \"Export accounts to file\". Vygenerovaný soubor JSON následně nahrajte sem.",
|
||||
"importProtonAuthGuide": "K exportu kódů použijte možnost „Exportovat“ v nastavení aplikace Proton Authenticator.",
|
||||
"exportCodes": "Exportovat kódy",
|
||||
"importLabel": "Importovat",
|
||||
"importInstruction": "Vyberte, prosím, soubor obsahující seznam Vašich kódů v následujícím formátu",
|
||||
@@ -124,6 +125,7 @@
|
||||
"authToChangeYourEmail": "Pro změnu svého e-mailu se, prosím, ověřte",
|
||||
"authToChangeYourPassword": "Pro změnu svého hesla se, prosím, ověřte",
|
||||
"authToViewSecrets": "Pro zobrazení svých tajných údajů se musíte ověřit",
|
||||
"authToInitiateSignIn": "Proveďte ověření a přihlaste se k zálohování.",
|
||||
"ok": "Ok",
|
||||
"cancel": "Zrušit",
|
||||
"yes": "Ano",
|
||||
@@ -171,6 +173,7 @@
|
||||
"invalidQRCode": "Neplatný QR kód",
|
||||
"noRecoveryKeyTitle": "Nemáte obnovovací klíč?",
|
||||
"enterEmailHint": "Zadejte svou e-mailovou adresu",
|
||||
"enterNewEmailHint": "Zadejte svou novou e-mailovou adresu",
|
||||
"invalidEmailTitle": "Neplatná e-mailová adresa",
|
||||
"invalidEmailMessage": "Prosím, zadejte platnou e-mailovou adresu.",
|
||||
"deleteAccount": "Odstranit účet",
|
||||
@@ -509,6 +512,19 @@
|
||||
"supportEnte": "Podpořte <bold-green>ente</bold-green>",
|
||||
"giveUsAStarOnGithub": "Dejte nám hvězdu na Githubu",
|
||||
"free5GB": "5GB zdarma na <bold-green>ente</bold-green> Fotky",
|
||||
"loginWithAuthAccount": "Přihlaste se pomocí svého účtu Auth",
|
||||
"freeStorageOffer": "10% sleva na <bold-green>ente</bold-green> fotky",
|
||||
"freeStorageOfferDescription": "Použijte kód \"AUTH\" pro získání 10% slevy na první rok"
|
||||
"freeStorageOfferDescription": "Použijte kód \"AUTH\" pro získání 10% slevy na první rok",
|
||||
"advanced": "Pokročilé",
|
||||
"algorithm": "Algoritmus",
|
||||
"type": "Typ",
|
||||
"period": "Období",
|
||||
"digits": "Digitální",
|
||||
"importFromGallery": "Importovat z galerie",
|
||||
"errorCouldNotReadImage": "Nelze přečíst vybraný obrazový soubor.",
|
||||
"errorInvalidQRCode": "Neplatný QR kód",
|
||||
"errorInvalidQRCodeBody": "Naskenovaný QR kód není platným účtem 2FA.",
|
||||
"errorNoQRCode": "Nenalezen žádný QR kód",
|
||||
"errorGenericTitle": "Došlo k chybě",
|
||||
"errorGenericBody": "Při importu došlo k neočekávané chybě."
|
||||
}
|
||||
@@ -111,6 +111,7 @@
|
||||
"importAegisGuide": "Verwenden Sie die Option \"Tresor exportieren\" in den Einstellungen von Aegis.\n\nFalls Ihr Tresor verschlüsselt ist, müssen Sie das Passwort für den Tresor eingeben, um ihn zu entschlüsseln.",
|
||||
"import2FasGuide": "Verwenden Sie unter \"Einstellungen → Backup\" die Option \"Exportieren\" in 2FAS.\n\nFalls Ihr Backup verschlüsselt ist, müssen Sie das Passwort eingeben, um das Backup zu entschlüsseln.",
|
||||
"importLastpassGuide": "Verwenden Sie die Option \"Konten übertragen → Konten in Datei exportieren\" in den Lastpass Authenticator Einstellungen. \nImportieren Sie anschließend die heruntergeladene JSON-Datei.",
|
||||
"importProtonAuthGuide": "Verwenden Sie die Option \"Exportieren\" in den Proton Authenticator Settings um Ihre Codes zu exportieren.",
|
||||
"exportCodes": "Codes exportieren",
|
||||
"importLabel": "Importieren",
|
||||
"importInstruction": "Bitte wählen Sie eine Datei die Codes in folgendem Format beinhaltet",
|
||||
@@ -519,5 +520,12 @@
|
||||
"algorithm": "Algorithmus",
|
||||
"type": "Typ",
|
||||
"period": "Periode",
|
||||
"digits": "Ziffern"
|
||||
"digits": "Ziffern",
|
||||
"importFromGallery": "Aus Galerie importieren",
|
||||
"errorCouldNotReadImage": "Die ausgewählte Bild-Datei konnte nicht verarbeitet werden.",
|
||||
"errorInvalidQRCode": "Ungültiger QR-Code",
|
||||
"errorInvalidQRCodeBody": "Der gescannte QR-Code ist kein gültiges 2FA-Konto.",
|
||||
"errorNoQRCode": "Kein QR-Code gefunden",
|
||||
"errorGenericTitle": "Ein Fehler ist aufgetreten",
|
||||
"errorGenericBody": "Beim Importieren ist ein unerwarteter Fehler aufgetreten."
|
||||
}
|
||||
@@ -111,6 +111,7 @@
|
||||
"importAegisGuide": "Utilisez l'option \"Exporter le coffre-fort\" dans les paramètres d'Aegis.\n\nSi votre coffre-fort est crypté, vous devrez saisir le mot de passe du coffre-fort pour déchiffrer le coffre-fort.",
|
||||
"import2FasGuide": "Utilisez l'option \"Paramètres->Sauvegarde -Export\" dans 2FAS.\n\nSi votre sauvegarde est chiffrée, vous devrez entrer le mot de passe pour déchiffrer la sauvegarde",
|
||||
"importLastpassGuide": "Utilisez l'option \"Transférer des comptes\" dans les paramètres de l'authentificateur Lastpass et appuyez sur \"Exporter des comptes vers un fichier\". Importez le JSON téléchargé.",
|
||||
"importProtonAuthGuide": "Utilisez l'option \"Export\" dans les paramètres de Proton Authenticator pour exporter vos codes.",
|
||||
"exportCodes": "Exporter les codes",
|
||||
"importLabel": "Importer",
|
||||
"importInstruction": "Veuillez sélectionner un fichier qui contient une liste de vos codes dans le format suivant",
|
||||
@@ -519,5 +520,12 @@
|
||||
"algorithm": "Algorithme",
|
||||
"type": "Type",
|
||||
"period": "Période",
|
||||
"digits": "Chiffres"
|
||||
"digits": "Chiffres",
|
||||
"importFromGallery": "Importer depuis la galerie",
|
||||
"errorCouldNotReadImage": "Impossible de lire le fichier sélectionné.",
|
||||
"errorInvalidQRCode": "QR Code invalide",
|
||||
"errorInvalidQRCodeBody": "Le code QR scanné n'est pas un compte 2FA valide.",
|
||||
"errorNoQRCode": "Aucun code QR trouvé",
|
||||
"errorGenericTitle": "Une erreur s'est produite",
|
||||
"errorGenericBody": "Une erreur inattendue est survenue lors de l'importation."
|
||||
}
|
||||
@@ -111,6 +111,7 @@
|
||||
"importAegisGuide": "Naudokite „Aegis“ nustatymuose esančią parinktį Eksportuoti slėptuvę.\n\nJei jūsų saugykla užšifruota, turėsite įvesti saugyklos slaptažodį, kad iššifruotumėte saugyklą.",
|
||||
"import2FasGuide": "Naudokite programoje 2FAS esančią parinktį „Settings->2FAS Backup->Export to file“.\n\nJei atsarginė kopija užšifruota, turėsite įvesti slaptažodį, kad iššifruotumėte atsarginę kopiją.",
|
||||
"importLastpassGuide": "Naudokite „Lastpass Authenticator“ nustatymuose esančią parinktį „Transfer accounts“ (perkelti paskyras) ir paspauskite „Export accounts to file“ (eksportuoti paskyras į failą). Importuokite atsisiųstą JSON failą.",
|
||||
"importProtonAuthGuide": "Naudokite „Proton Authenticator“ nustatymuose esančią parinktį „Export“ (eksportuoti), kad eksportuotumėte savo kodus.",
|
||||
"exportCodes": "Eksportuoti kodus",
|
||||
"importLabel": "Importuoti",
|
||||
"importInstruction": "Pasirinkite failą, kuriame yra tokio formato jūsų kodų sąrašas",
|
||||
@@ -519,5 +520,12 @@
|
||||
"algorithm": "Algoritmas",
|
||||
"type": "Tipas",
|
||||
"period": "Laikotarpis",
|
||||
"digits": "Skaitmenys"
|
||||
"digits": "Skaitmenys",
|
||||
"importFromGallery": "Importuoti iš galerijos",
|
||||
"errorCouldNotReadImage": "Nepavyko perskaityti pasirinkto vaizdo failo.",
|
||||
"errorInvalidQRCode": "Netinkamas QR kodas",
|
||||
"errorInvalidQRCodeBody": "Nuskenuotas QR kodas nėra tinkama 2FA paskyra.",
|
||||
"errorNoQRCode": "QR kodas nerastas.",
|
||||
"errorGenericTitle": "Įvyko klaida",
|
||||
"errorGenericBody": "Importuojant įvyko netikėta klaida."
|
||||
}
|
||||
@@ -111,6 +111,7 @@
|
||||
"importAegisGuide": "Użyj opcji \"Eksportuj sejf\" w ustawieniach Aegis.\n\nJeśli twój sejf jest zaszyfrowany, musisz wprowadzić hasło sejfu, aby odszyfrować sejf.",
|
||||
"import2FasGuide": "Użyj opcji \"Ustawienia->Kopia Zapasowa-Eksport\" w 2FAS.\n\nJeśli twoja kopia zapasowa jest zaszyfrowana, musisz wprowadzić hasło, aby odszyfrować kopię zapasową",
|
||||
"importLastpassGuide": "Użyj opcji \"Przenieś konta\" w Ustawieniach Lastpass Authenticator i naciśnij \"Eksportuj konta do pliku\". Zaimportuj pobrany plik JSON.",
|
||||
"importProtonAuthGuide": "Użyj opcji „Eksportuj” w ustawieniach Proton Authenticator, aby wyeksportować kody.",
|
||||
"exportCodes": "Eksportuj kody",
|
||||
"importLabel": "Importuj",
|
||||
"importInstruction": "Wybierz plik, który zawiera listę twoich kodów w następującym formacie",
|
||||
@@ -519,5 +520,12 @@
|
||||
"algorithm": "Algorytm",
|
||||
"type": "Rodzaj",
|
||||
"period": "Okres",
|
||||
"digits": "Cyfry"
|
||||
"digits": "Cyfry",
|
||||
"importFromGallery": "Importuj z galerii",
|
||||
"errorCouldNotReadImage": "Nie można odczytać wybranego pliku obrazu.",
|
||||
"errorInvalidQRCode": "Nieprawidłowy kod QR",
|
||||
"errorInvalidQRCodeBody": "Zeskanowany kod QR nie wskazuje na prawidłowe konto 2FA.",
|
||||
"errorNoQRCode": "Nie znaleziono kodu QR",
|
||||
"errorGenericTitle": "Wystąpił błąd",
|
||||
"errorGenericBody": "Podczas importowania wystąpił nieoczekiwany błąd."
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
"incorrectDetails": "Felaktiga uppgifter",
|
||||
"pleaseVerifyDetails": "Kontrollera dina detaljer och försök igen",
|
||||
"codeIssuerHint": "Utfärdare",
|
||||
"codeSecretKeyHint": "Secret Key",
|
||||
"codeSecretKeyHint": "Hemlig nyckel",
|
||||
"secret": "Säkerhetsnyckel",
|
||||
"all": "Alla",
|
||||
"notes": "Anteckningar",
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codeAccountHint": "Konto (du@domän.com)",
|
||||
"codeAccountHint": "Konto (du@domain.com)",
|
||||
"codeTagHint": "Tagg",
|
||||
"accountKeyType": "Typ av nyckel",
|
||||
"sessionExpired": "Sessionen har gått ut",
|
||||
@@ -68,7 +68,7 @@
|
||||
"reportABug": "Rapportera en bugg",
|
||||
"crashAndErrorReporting": "Krasch och felrapportering",
|
||||
"reportBug": "Rapportera bugg",
|
||||
"emailUsMessage": "Skicka e-mail till {email}",
|
||||
"emailUsMessage": "Skicka e-post till {email}",
|
||||
"@emailUsMessage": {
|
||||
"placeholders": {
|
||||
"email": {
|
||||
@@ -79,7 +79,7 @@
|
||||
"contactSupport": "Kontakta support",
|
||||
"rateUsOnStore": "Betygsätt på {storeName}",
|
||||
"blog": "Blogg",
|
||||
"merchandise": "Merchandise",
|
||||
"merchandise": "Produkter",
|
||||
"verifyPassword": "Bekräfta lösenord",
|
||||
"pleaseWait": "Vänligen vänta...",
|
||||
"generatingEncryptionKeysTitle": "Skapar krypteringsnycklar...",
|
||||
@@ -104,13 +104,14 @@
|
||||
"importFromApp": "Importera koder från {appName}",
|
||||
"importGoogleAuthGuide": "Exportera dina konton från Google Authenticator till en QR-kod med alternativet \"Överföra konton\". Använd sedan en annan enhet och skanna QR-koden.\n\nTips: Du kan använda din bärbara dators webbkamera för att ta en bild av QR-koden.",
|
||||
"importSelectJsonFile": "Välj JSON-fil",
|
||||
"importSelectAppExport": "Välj {appName} exportfil",
|
||||
"importSelectAppExport": "Välj {appName} exporteringsfil",
|
||||
"importEnteEncGuide": "Välj den krypterade JSON-filen som exporteras från Ente",
|
||||
"importRaivoGuide": "Använd alternativet \"Exportera OTPs till zip-arkiv\" i Raivos inställningar.\n\nExtrahera zip-filen och importera JSON-filen.",
|
||||
"importBitwardenGuide": "Använd alternativet \"Exportera valv\" inom Bitwarden Tools och importera den okrypterade JSON-filen.",
|
||||
"importAegisGuide": "Använd alternativet \"Exportera valvet\" i Aegis inställningar.\n\nOm ditt valv är krypterat måste du ange valvlösenordet för att dekryptera valvet.",
|
||||
"import2FasGuide": "Använd alternativet \"Inställningar->Säkerhetskopiera -Exportera\" i 2FAS.\n\nOm din säkerhetskopia är krypterad måste du ange lösenordet för att dekryptera säkerhetskopian.",
|
||||
"importLastpassGuide": "Använd alternativet \"Överför konton\" i LastPass Authenticators inställningar och tryck på \"Exportera konton till fil\". Importera JSON-filen som laddas ner.",
|
||||
"importProtonAuthGuide": "Använd alternativet \"Exportera\" i Proton Authenticator-inställningarna för att exportera koder.",
|
||||
"exportCodes": "Exportera koder",
|
||||
"importLabel": "Importera",
|
||||
"importInstruction": "Vänligen välj en fil som innehåller en lista över dina koder i följande format",
|
||||
@@ -119,11 +120,11 @@
|
||||
"emailVerificationToggle": "E-postverifiering",
|
||||
"emailVerificationEnableWarning": "För att undvika att bli låst från ditt konto, se till att spara en kopia av din e-post 2FA utanför Ente Auth innan du aktiverar e-postverifiering.",
|
||||
"authToChangeEmailVerificationSetting": "Autentisera för att ändra din e-postadress",
|
||||
"authenticateGeneric": "Var god autentisera",
|
||||
"authenticateGeneric": "Vänligen autentisera",
|
||||
"authToViewYourRecoveryKey": "Autentisera för att visa din återställningsnyckel",
|
||||
"authToChangeYourEmail": "Autentisera för att ändra din e-postadress",
|
||||
"authToChangeYourPassword": "Autentisera för att ändra ditt lösenord",
|
||||
"authToViewSecrets": "Autentisera för att visa din återställningsnyckel",
|
||||
"authToViewSecrets": "Vänligen autentisera för att visa din återställningsnyckel",
|
||||
"authToInitiateSignIn": "Vänligen autentisera för att initiera inloggning för säkerhetskopiering.",
|
||||
"ok": "OK",
|
||||
"cancel": "Avbryt",
|
||||
@@ -147,7 +148,7 @@
|
||||
"leaveFamily": "Lämna familjen",
|
||||
"leaveFamilyMessage": "Är du säker på att du vill lämna familjeplanen?",
|
||||
"inFamilyPlanMessage": "Du är på en familjeplan!",
|
||||
"hintForMobile": "Håll i på en kod för att redigera eller ta bort.",
|
||||
"hintForMobile": "Tryck länge på en kod för att redigera eller ta bort.",
|
||||
"hintForDesktop": "Högerklicka på en kod för att redigera eller ta bort.",
|
||||
"scan": "Skanna",
|
||||
"scanACode": "Skanna kod",
|
||||
@@ -191,7 +192,7 @@
|
||||
"oopsSomethingWentWrong": "Hoppsan! Något gick fel.",
|
||||
"selectLanguage": "Välj språk",
|
||||
"language": "Språk",
|
||||
"social": "Social",
|
||||
"social": "Socialt",
|
||||
"security": "Säkerhet",
|
||||
"lockscreen": "Låsskärm",
|
||||
"authToChangeLockscreenSetting": "Vänligen autentisera för att ändra låsskärms inställningar",
|
||||
@@ -200,7 +201,7 @@
|
||||
"authToViewYourActiveSessions": "Autentisera för att visa dina aktiva sessioner",
|
||||
"searchHint": "Sök...",
|
||||
"search": "Sök",
|
||||
"sorryUnableToGenCode": "Tyvärr, det gick inte att generera en kod för {issuerName}",
|
||||
"sorryUnableToGenCode": "Tyvärr, kunde inte generera en kod för {issuerName}",
|
||||
"noResult": "Inga resultat",
|
||||
"addCode": "Lägg till kod",
|
||||
"scanAQrCode": "Skanna en QR-kod",
|
||||
@@ -215,7 +216,7 @@
|
||||
"error": "Fel",
|
||||
"recoveryKeyCopiedToClipboard": "Återställningsnyckel kopierad till urklipp",
|
||||
"recoveryKeyOnForgotPassword": "Om du glömmer ditt lösenord är det enda sättet du kan återställa dina data med denna nyckel.",
|
||||
"recoveryKeySaveDescription": "Vi lagrar inte och har därför inte åtkomst till denna nyckel, vänligen spara denna 24 ords nyckel på en säker plats.",
|
||||
"recoveryKeySaveDescription": "Vi lagrar inte och har därför inte åtkomst till denna nyckel, vänligen spara denna 24 ordsnyckeln på en säker plats.",
|
||||
"doThisLater": "Gör detta senare",
|
||||
"saveKey": "Spara nyckel",
|
||||
"save": "Spara",
|
||||
@@ -254,7 +255,7 @@
|
||||
"insecureDevice": "Osäker enhet",
|
||||
"sorryWeCouldNotGenerateSecureKeysOnThisDevicennplease": "Tyvärr, kunde vi inte generera säkra nycklar på den här enheten.\n\nvänligen registrera dig från en annan enhet.",
|
||||
"howItWorks": "Så här fungerar det",
|
||||
"ackPasswordLostWarning": "Jag förstår att om jag förlorar mitt lösenord kan jag förlora mina data eftersom min data är <underline>end-to-end-krypterad</underline>.",
|
||||
"ackPasswordLostWarning": "Jag förstår att om jag förlorar mitt lösenord kan jag förlora mina data eftersom min data är <underline>totalsträckskrypterad</underline>.",
|
||||
"loginTerms": "Jag samtycker till <u-terms>användarvillkoren</u-terms> och <u-policy>integritetspolicyn</u-policy>",
|
||||
"logInLabel": "Logga in",
|
||||
"logout": "Logga ut",
|
||||
@@ -278,7 +279,7 @@
|
||||
"recoveryKeyVerifyReason": "Din återställningsnyckel är det enda sättet att återställa dina foton om du glömmer ditt lösenord. Du hittar din återställningsnyckel i Inställningar > Säkerhet.\n\nAnge din återställningsnyckel här för att verifiera att du har sparat den ordentligt.",
|
||||
"confirmYourRecoveryKey": "Bekräfta din återställningsnyckel",
|
||||
"confirm": "Bekräfta",
|
||||
"emailYourLogs": "Maila dina loggar",
|
||||
"emailYourLogs": "E-posta dina loggar",
|
||||
"pleaseSendTheLogsTo": "Vänligen skicka loggarna till \n{toEmail}",
|
||||
"copyEmailAddress": "Kopiera e-postadress",
|
||||
"exportLogs": "Exportera loggar",
|
||||
@@ -297,7 +298,7 @@
|
||||
"criticalUpdateAvailable": "Kritisk uppdatering tillgänglig",
|
||||
"updateAvailable": "Uppdatering tillgänglig",
|
||||
"update": "Uppdatera",
|
||||
"checking": "Kontrollerar ...",
|
||||
"checking": "Kontrollerar...",
|
||||
"youAreOnTheLatestVersion": "Du är på den senaste versionen",
|
||||
"warning": "Varning",
|
||||
"exportWarningDesc": "Den exporterade filen innehåller känslig information. Förvara den på ett säkert sätt.",
|
||||
@@ -306,7 +307,7 @@
|
||||
"description": "Text for the button to confirm the user understands the warning"
|
||||
},
|
||||
"authToExportCodes": "Autentisera för att exportera dina koder",
|
||||
"importSuccessTitle": "Jippi!",
|
||||
"importSuccessTitle": "Hurra!",
|
||||
"importSuccessDesc": "Du har importerat {count} koder!",
|
||||
"@importSuccessDesc": {
|
||||
"placeholders": {
|
||||
@@ -324,7 +325,7 @@
|
||||
"checkInboxAndSpamFolder": "Vänligen kontrollera din inkorg (och skräppost) för att slutföra verifieringen",
|
||||
"tapToEnterCode": "Tryck för att ange kod",
|
||||
"resendEmail": "Skicka e-post igen",
|
||||
"weHaveSendEmailTo": "Vi har skickat ett mail till <green>{email}</green>",
|
||||
"weHaveSendEmailTo": "Vi har skickat ett e-postmeddelande till <green>{email}</green>",
|
||||
"@weHaveSendEmailTo": {
|
||||
"description": "Text to indicate that we have sent a mail to the user",
|
||||
"placeholders": {
|
||||
@@ -362,7 +363,7 @@
|
||||
"selectExportFormat": "Välj exportformat",
|
||||
"exportDialogDesc": "Krypterad export skyddas av ett lösenord som du väljer.",
|
||||
"encrypted": "Krypterad",
|
||||
"plainText": "Enkel text",
|
||||
"plainText": "Oformaterad text",
|
||||
"passwordToEncryptExport": "Lösenord för att kryptera export",
|
||||
"export": "Exportera",
|
||||
"useOffline": "Använd utan säkerhetskopior",
|
||||
@@ -374,14 +375,14 @@
|
||||
"compactMode": "Kompakt läge",
|
||||
"shouldHideCode": "Dölj koder",
|
||||
"doubleTapToViewHiddenCode": "Du kan dubbeltrycka på en post för att visa koden",
|
||||
"focusOnSearchBar": "Fokusera på sök vid appstart",
|
||||
"focusOnSearchBar": "Fokusera på sök vid uppstart av app",
|
||||
"confirmUpdatingkey": "Är du säker på att du vill uppdatera den hemliga nyckeln?",
|
||||
"minimizeAppOnCopy": "Minimera appen vid kopiering",
|
||||
"editCodeAuthMessage": "Autentisera för att redigera kod",
|
||||
"deleteCodeAuthMessage": "Autentisera för att radera kod",
|
||||
"showQRAuthMessage": "Autentisera för att visa QR-kod",
|
||||
"confirmAccountDeleteTitle": "Bekräfta radering av kontot",
|
||||
"confirmAccountDeleteMessage": "Detta konto är kopplat till andra Ente apps, om du använder någon.\n\nDina uppladdade data, över alla Ente appar, kommer att schemaläggas för radering och ditt konto kommer att raderas permanent.",
|
||||
"confirmAccountDeleteMessage": "Detta konto är kopplat till andra Ente applikationer, om du använder någon.\n\nDina uppladdade data, över alla Ente applikationer, kommer att schemaläggas för radering och ditt konto kommer att raderas permanent.",
|
||||
"androidBiometricHint": "Verifiera identitet",
|
||||
"@androidBiometricHint": {
|
||||
"description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters."
|
||||
@@ -390,7 +391,7 @@
|
||||
"@androidBiometricNotRecognized": {
|
||||
"description": "Message to let the user know that authentication was failed. It is used on Android side. Maximum 60 characters."
|
||||
},
|
||||
"androidBiometricSuccess": "Slutförd",
|
||||
"androidBiometricSuccess": "Lyckades",
|
||||
"@androidBiometricSuccess": {
|
||||
"description": "Message to let the user know that authentication was successful. It is used on Android side. Maximum 60 characters."
|
||||
},
|
||||
@@ -441,7 +442,7 @@
|
||||
"signOutOtherDevices": "Logga ut andra enheter",
|
||||
"doNotSignOut": "Logga inte ut",
|
||||
"hearUsWhereTitle": "Hur hörde du talas om Ente? (valfritt)",
|
||||
"hearUsExplanation": "Vi spårar inte appinstallationer, Det skulle hjälpa oss om du berättade var du hittade oss!",
|
||||
"hearUsExplanation": "Vi spårar inte installationer. Det skulle hjälpa oss om du berättade hur du hittade oss!",
|
||||
"recoveryKeySaved": "Återställningsnyckel sparad i nedladdningsmappen!",
|
||||
"waitingForBrowserRequest": "Väntar på webbläsarbegäran...",
|
||||
"waitingForVerification": "Väntar på verifiering...",
|
||||
@@ -488,6 +489,8 @@
|
||||
"hideContent": "Dölj innehåll",
|
||||
"hideContentDescriptionAndroid": "Döljer appinnehåll i app-växlaren och inaktiverar skärmdumpar",
|
||||
"hideContentDescriptioniOS": "Döljer appinnehåll i app-växlaren",
|
||||
"autoLockFeatureDescription": "Tid efter vilken appen låses efter att ha satts i bakgrunden",
|
||||
"appLockDescription": "Välj mellan enhetens förvalda låsskärm och en anpassad låsskärm med en PIN-kod eller lösenord.",
|
||||
"pinLock": "Pinkodslås",
|
||||
"enterPin": "Ange PIN-kod",
|
||||
"setNewPin": "Ställ in ny PIN-kod",
|
||||
@@ -498,9 +501,31 @@
|
||||
"appLockOfflineModeWarning": "Du har valt att fortsätta utan säkerhetskopior. Om du glömmer ditt applås, kommer du att bli utelåst från att komma åt dina data.",
|
||||
"duplicateCodes": "Dubblettkoder",
|
||||
"noDuplicates": "✨ Inga dubbletter",
|
||||
"youveNoDuplicateCodesThatCanBeCleared": "Du har inga dubbla koder som kan rensas",
|
||||
"deduplicateCodes": "Deduplicera koder",
|
||||
"deselectAll": "Avmarkera alla",
|
||||
"selectAll": "Markera alla",
|
||||
"deleteDuplicates": "Radera dubbletter",
|
||||
"plainHTML": "Ren HTML"
|
||||
"plainHTML": "Ren HTML",
|
||||
"tellUsWhatYouThink": "Berätta vad du tycker",
|
||||
"dropReviewiOS": "Skriv en recension på App Store",
|
||||
"dropReviewAndroid": "Skriv en recension på Play Store",
|
||||
"supportEnte": "Stöd <bold-green>ente</bold-green>",
|
||||
"giveUsAStarOnGithub": "Ge oss en stjärna på Github",
|
||||
"free5GB": "5 GB gratis på <bold-green>ente</bold-green> Foton",
|
||||
"loginWithAuthAccount": "Logga in med ditt Auth-konto",
|
||||
"freeStorageOffer": "10% rabatt på <bold-green>ente</bold-green> foton",
|
||||
"freeStorageOfferDescription": "Använd koden \"AUTH\" för att få 10% rabatt första året",
|
||||
"advanced": "Avancerad",
|
||||
"algorithm": "Algoritm",
|
||||
"type": "Typ",
|
||||
"period": "Tidsperiod",
|
||||
"digits": "Siffror",
|
||||
"importFromGallery": "Importera från galleri",
|
||||
"errorCouldNotReadImage": "Kunde inte läsa den valda bildfilen.",
|
||||
"errorInvalidQRCode": "Ogiltig QR-kod",
|
||||
"errorInvalidQRCodeBody": "Den skannade QR-koden är inte ett giltigt 2FA konto.",
|
||||
"errorNoQRCode": "Ingen QR-kod hittades",
|
||||
"errorGenericTitle": "Ett fel inträffade",
|
||||
"errorGenericBody": "Ett oväntat fel inträffade vid import."
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
"@counterAppBarTitle": {
|
||||
"description": "Text shown in the AppBar of the Counter Page"
|
||||
},
|
||||
"onBoardingBody": "妥善保管您的两步验证码",
|
||||
"onBoardingBody": "妥善保管您的双重认证代码",
|
||||
"onBoardingGetStarted": "开始",
|
||||
"setupFirstAccount": "设置您的第一个账户",
|
||||
"importScanQrCode": "扫描二维码",
|
||||
@@ -111,13 +111,14 @@
|
||||
"importAegisGuide": "使用 Aegis 设置中的“导出密码库”选项。\n\n如果您的密码库已加密,则需要输入密码库密码才能解密密码库。",
|
||||
"import2FasGuide": "使用 2FAS 中的“设置 -> 备份 -> 导出”选项。\n\n如果您的备份已加密,则需要输入密码来解密备份",
|
||||
"importLastpassGuide": "使用 Lastpass Authenticator 设置中的“转移账户”选项,然后按“将账户导出到文件”。导入下载的 JSON。",
|
||||
"importProtonAuthGuide": "使用 Proton Authenticator 设置中的“导出”选项导出您的代码。",
|
||||
"exportCodes": "导出代码",
|
||||
"importLabel": "导入",
|
||||
"importInstruction": "请选择一个包含以下格式的代码列表的文件",
|
||||
"importCodeDelimiterInfo": "代码可以用逗号或换行符分隔",
|
||||
"selectFile": "选择文件",
|
||||
"emailVerificationToggle": "电子邮件验证",
|
||||
"emailVerificationEnableWarning": "为避免被锁在您的账户之外,请在启用电子邮件验证之前确保在 Ente Auth 之外存储电子邮件两步验证的副本。",
|
||||
"emailVerificationEnableWarning": "为避免被锁在您的账户之外,请在启用电子邮件验证之前确保在 Ente Auth 之外存储电子邮件双重认证的副本。",
|
||||
"authToChangeEmailVerificationSetting": "请进行身份验证以更改电子邮件验证",
|
||||
"authenticateGeneric": "请验证",
|
||||
"authToViewYourRecoveryKey": "请验证以查看您的恢复密钥",
|
||||
@@ -155,7 +156,7 @@
|
||||
"verifyEmail": "验证电子邮件",
|
||||
"enterCodeHint": "从你的身份验证器应用中\n输入6位数字代码",
|
||||
"lostDeviceTitle": "丢失了设备吗?",
|
||||
"twoFactorAuthTitle": "两步验证",
|
||||
"twoFactorAuthTitle": "双重认证",
|
||||
"passkeyAuthTitle": "通行密钥验证",
|
||||
"verifyPasskey": "验证通行密钥",
|
||||
"loginWithTOTP": "使用 TOTP 登录",
|
||||
@@ -519,5 +520,12 @@
|
||||
"algorithm": "算法",
|
||||
"type": "类型",
|
||||
"period": "周期",
|
||||
"digits": "数字"
|
||||
"digits": "数字",
|
||||
"importFromGallery": "从图库导入",
|
||||
"errorCouldNotReadImage": "无法读取所选图片文件。",
|
||||
"errorInvalidQRCode": "二维码无效",
|
||||
"errorInvalidQRCodeBody": "扫描的二维码不是有效的双重认证账户。",
|
||||
"errorNoQRCode": "未找到二维码",
|
||||
"errorGenericTitle": "出错了",
|
||||
"errorGenericBody": "导入时发生意外错误。"
|
||||
}
|
||||
1
mobile/apps/photos/.gitignore
vendored
1
mobile/apps/photos/.gitignore
vendored
@@ -52,3 +52,4 @@ lib/generated/intl/app_localizations*.dart
|
||||
# Generated rust bindings
|
||||
lib/src/rust/*
|
||||
rust/src/frb_generated*
|
||||
test/**/*.mocks.dart
|
||||
|
||||
@@ -75,3 +75,4 @@ analyzer:
|
||||
- rust_builder/**
|
||||
- lib/src/rust/**
|
||||
- rust/src/**
|
||||
- test/**
|
||||
|
||||
BIN
mobile/apps/photos/assets/2.0x/enable-streaming-static.png
Normal file
BIN
mobile/apps/photos/assets/2.0x/enable-streaming-static.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 175 KiB |
BIN
mobile/apps/photos/assets/3.0x/enable-streaming-static.png
Normal file
BIN
mobile/apps/photos/assets/3.0x/enable-streaming-static.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 396 KiB |
BIN
mobile/apps/photos/assets/enable-streaming-static.png
Normal file
BIN
mobile/apps/photos/assets/enable-streaming-static.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
File diff suppressed because it is too large
Load Diff
@@ -1,36 +1,36 @@
|
||||
ente is a simple app to backup and share your photos and videos.
|
||||
ente je jednoduchá aplikace pro automatické zálohování a organizaci vašich fotek a videí.
|
||||
|
||||
If you've been looking for a privacy-friendly alternative to Google Photos, you've come to the right place. With ente, they are stored end-to-end encrypted (e2ee). This means that only you can view them.
|
||||
Pokud hledáte alternativu ke službě Google Photos, která respektuje vaše soukromí, jste na správném místě. S Ente jsou uloženy s koncovým "end-to-end" šifrováním (e2ee). To znamená, že je můžete vidět pouze vy.
|
||||
|
||||
We have open-source apps across Android, iOS, web and desktop, and your photos will seamlessly sync between all of them in an end-to-end encrypted (e2ee) manner.
|
||||
Máme aplikace napříč všemi platformami a vaše fotky se budou bezproblémově synchronizovat mezi všemi vašimi zařízeními tak, aby byly koncově šifrovány mezi jednotlivými zařízeními (e2ee).
|
||||
|
||||
ente also makes it simple to share your albums with your loved ones, even if they aren't on ente. You can share publicly viewable links, where they can view your album and collaborate by adding photos to it, even without an account or app.
|
||||
ente také usnadňuje sdílení vašich alb s vašimi blízkými, i když nejsou na ente. Můžete sdílet veřejně přístupné odkazy, kde si mohou prohlížet vaše album a spolupracovat přidáváním fotografií, a to i bez účtu nebo aplikace.
|
||||
|
||||
Your encrypted data is replicated to 3 different locations, including a fall-out shelter in Paris. We take posterity seriously and make it easy to ensure that your memories outlive you.
|
||||
Vaše šifrovaná data jsou uložena na 3 místech, včetně protiatomového krytu v Paříži. Bereme budoucnost vážně a usnadňujeme vám zajistit, aby vaše vzpomínky přežily vás samotné.
|
||||
|
||||
We are here to make the safest photos app ever, come join our journey!
|
||||
Jsme tu, abychom vytvořili nejbezpečnější aplikaci pro fotografie, jaká kdy byla. Přidejte se k nám!
|
||||
|
||||
FEATURES
|
||||
- Original quality backups, because every pixel is important
|
||||
- Family plans, so you can share storage with your family
|
||||
- Collaborative albums, so you can pool together photos after a trip
|
||||
- Shared folders, in case you want your partner to enjoy your "Camera" clicks
|
||||
- Album links, that can be protected with a password
|
||||
- Ability to free up space, by removing files that have been safely backed up
|
||||
- Human support, because you're worth it
|
||||
- Descriptions, so you can caption your memories and find them easily
|
||||
- Image editor, to add finishing touches
|
||||
- Favorite, hide and relive your memories, for they are precious
|
||||
- One-click import from Google, Apple, your hard drive and more
|
||||
- Dark theme, because your photos look good in it
|
||||
- 2FA, 3FA, biometric auth
|
||||
- and a LOT more!
|
||||
VLASTNOSTI
|
||||
- Zálohování v původní kvalitě, protože každý pixel je důležitý
|
||||
- Rodinné plány, takže můžete sdílet úložiště s rodinou
|
||||
- Společná alba, do kterých můžete po cestě shromažďovat fotografie
|
||||
- Sdílené složky, aby i váš partner mohl obdivovat vaše fotografie
|
||||
- Odkazy na alba, které lze chránit heslem a nastavit jejich platnost
|
||||
- Možnost uvolnit místo odstraněním souborů, které byly bezpečně zálohovány
|
||||
- Lidská podpora, protože si to zasloužíte
|
||||
- Popisy, abyste mohli své vzpomínky opatřit popisky a snadno je najít
|
||||
- Editor obrázků pro doladění detailů
|
||||
- Označte si své oblíbené fotografie, skryjte je a nebo je prožijte znovu pomocí vzpomínek
|
||||
- Import jedním kliknutím z Google, Apple, pevného disku a dalších zdrojů
|
||||
- Tmavý motiv, protože vaše fotografie v něm vypadají dobře
|
||||
- 2FA, 3FA, biometrické ověření
|
||||
- a ještě MNOHEM víc!
|
||||
|
||||
OPRÁVNĚNÍ
|
||||
ente requests for certain permissions to serve the purpose of a photo storage provider, which can be reviewed here: https://github.com/ente-io/ente/blob/f-droid/mobile/android/permissions.md
|
||||
ente žádá o určitá oprávnění, aby mohla plnit funkci poskytovatele úložiště fotografií. Tyto oprávnění si můžete prohlédnout zde: https://github.com/ente-io/ente/blob/f-droid/mobile/android/permissions.md
|
||||
|
||||
PRICING
|
||||
We don't offer forever free plans, because it is important to us that we remain sustainable and withstand the test of time. Instead we offer affordable plans that you can freely share with your family. You can find more information at ente.io.
|
||||
CENY
|
||||
Nenabízíme doživotní tarify, protože je pro nás důležité zůstat udržitelnými a obstát ve zkoušce času. Místo toho nabízíme cenově dostupné plány, které můžete svobodně sdílet se svou rodinou. Více informací najdete na ente.io.
|
||||
|
||||
PODPORA
|
||||
We take pride in offering human support. If you are our paid customer, you can reach out to team@ente.io and expect a response from our team within 24 hours.
|
||||
Jsme hrdí na to, že poskytujeme lidskou podporu. Pokud máte placený tarif, můžete se obrátit na team@ente.io a do 24 hodin od našeho týmu očekávat odpověď.
|
||||
|
||||
@@ -1 +1 @@
|
||||
ente is an end-to-end encrypted photo storage app
|
||||
ente je aplikace pro ukládání fotografií a videí s koncovým šifrováním
|
||||
@@ -1 +1 @@
|
||||
ente - encrypted photo storage
|
||||
ente – šifrované úložiště fotografií
|
||||
@@ -1,33 +1,33 @@
|
||||
Ente is a simple app to automatically backup and organize your photos and videos.
|
||||
Ente je jednoduchá aplikace pro automatické zálohování a organizaci vašich fotek a videí.
|
||||
|
||||
If you've been looking for a privacy-friendly alternative to preserve your memories, you've come to the right place. With Ente, they are stored end-to-end encrypted (e2ee). This means that only you can view them.
|
||||
Pokud hledáte alternativu, která respektuje vaše soukromí a umožní vám uchovat vaše vzpomínky, jste na správném místě. S Ente jsou uloženy s koncovým "end-to-end" šifrováním (e2ee). To znamená, že je můžete vidět pouze vy.
|
||||
|
||||
We have apps across all platforms, and your photos will seamlessly sync between all your devices in an end-to-end encrypted (e2ee) manner.
|
||||
Máme aplikace napříč všemi platformami a vaše fotky se budou bezproblémově synchronizovat mezi všemi vašimi zařízeními tak, aby byly koncově šifrovány mezi jednotlivými zařízeními (e2ee).
|
||||
|
||||
Ente also makes it simple to share your albums with your loved ones. You can either share them directly with other Ente users, end-to-end encrypted; or with publicly viewable links.
|
||||
Ente také zjednodušuje sdílení alb s vašimi blízkými. Můžete je buď sdílet přímo s ostatními uživateli Ente, koncově šifrované; nebo s pomocí veřejně přístupných odkazů.
|
||||
|
||||
Your encrypted data is stored across multiple locations, including a fall-out shelter in Paris. We take posterity seriously and make it easy to ensure that your memories outlive you.
|
||||
Vaše šifrovaná data jsou uložena na několika místech, včetně protiatomového krytu v Paříži. Bereme budoucnost vážně a usnadňujeme vám zajistit, aby vaše vzpomínky přežily vás samotné.
|
||||
|
||||
We are here to make the safest photos app ever, come join our journey!
|
||||
Jsme tu, abychom vytvořili nejbezpečnější aplikaci pro fotografie, jaká kdy byla. Přidejte se k nám!
|
||||
|
||||
FEATURES
|
||||
- Original quality backups, because every pixel is important
|
||||
- Family plans, so you can share storage with your family
|
||||
- Shared folders, in case you want your partner to enjoy your "Camera" clicks
|
||||
- Album links, that can be protected with a password and set to expire
|
||||
- Ability to free up space, by removing files that have been safely backed up
|
||||
- Image editor, to add finishing touches
|
||||
- Favorite, hide and relive your memories, for they are precious
|
||||
- One-click import from all major storage providers
|
||||
- Dark theme, because your photos look good in it
|
||||
- 2FA, 3FA, biometric auth
|
||||
- and a LOT more!
|
||||
VLASTNOSTI
|
||||
- Zálohování v původní kvalitě, protože každý pixel je důležitý
|
||||
- Rodinné plány, takže můžete sdílet úložiště s rodinou
|
||||
- Sdílené složky, aby i váš partner mohl obdivovat vaše fotografie
|
||||
- Odkazy na alba, které lze chránit heslem a nastavit jejich platnost
|
||||
- Možnost uvolnit místo odstraněním souborů, které byly bezpečně zálohovány
|
||||
- Editor obrázků, pro finální doladění detailů
|
||||
- Označte si své oblíbené fotografie, skryjte je a nebo je prožijte znovu pomocí vzpomínek
|
||||
- Import jedním kliknutím ze všech velkých poskytovatelů úložišť
|
||||
- Tmavý motiv, protože vaše fotografie v něm vypadají dobře
|
||||
- 2FA, 3FA, biometrické ověření
|
||||
- a ještě MNOHEM víc!
|
||||
|
||||
PRICING
|
||||
We don't offer forever free plans, because it is important to us that we remain sustainable and withstand the test of time. Instead we offer affordable plans that you can freely share with your family. You can find more information at ente.io.
|
||||
CENY
|
||||
Nenabízíme doživotní tarify, protože je pro nás důležité zůstat udržitelnými a obstát ve zkoušce času. Místo toho nabízíme cenově dostupné plány, které můžete svobodně sdílet se svou rodinou. Více informací najdete na ente.io.
|
||||
|
||||
SUPPORT
|
||||
We take pride in offering human support. If you are our paid customer, you can reach out to team@ente.io and expect a response from our team within 24 hours.
|
||||
PODPORA
|
||||
Jsme hrdí na to, že poskytujeme lidskou podporu. Pokud máte placený tarif, můžete se obrátit na team@ente.io a do 24 hodin od našeho týmu očekávat odpověď.
|
||||
|
||||
TERMS
|
||||
PODMÍNKY
|
||||
https://ente.io/terms
|
||||
|
||||
@@ -1 +1 @@
|
||||
photos,photography,family,privacy,cloud,backup,videos,photo,encryption,storage,album,alternative
|
||||
fotky,fotografie,rodina,soukromí,cloud,zálohování,videa,fotka,šifrování,úložiště,album,alternativa
|
||||
|
||||
@@ -1 +1 @@
|
||||
Ente Photos
|
||||
Ente Fotky
|
||||
|
||||
@@ -1 +1 @@
|
||||
Encrypted photo storage
|
||||
Šifrované úložiště fotografií
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
Ente is a simple app to automatically backup and organize your photos and videos.
|
||||
Ente je jednoduchá aplikace pro automatické zálohování a organizaci vašich fotek a videí.
|
||||
|
||||
If you've been looking for a privacy-friendly alternative to preserve your memories, you've come to the right place. With Ente, they are stored end-to-end encrypted (e2ee). This means that only you can view them.
|
||||
Pokud hledáte alternativu, která respektuje vaše soukromí a umožní vám uchovat vaše vzpomínky, jste na správném místě. S Ente jsou uloženy s koncovým "end-to-end" šifrováním (e2ee). To znamená, že je můžete vidět pouze vy.
|
||||
|
||||
We have apps across Android, iOS, web and Desktop, and your photos will seamlessly sync between all your devices in an end-to-end encrypted (e2ee) manner.
|
||||
Máme aplikace napříč všemi platformami a vaše fotky se budou bezproblémově synchronizovat mezi všemi vašimi zařízeními tak, aby byly koncově šifrovány mezi jednotlivými zařízeními (e2ee).
|
||||
|
||||
Ente also makes it simple to share your albums with your loved ones. You can either share them directly with other Ente users, end-to-end encrypted; or with publicly viewable links.
|
||||
Ente také zjednodušuje sdílení alb s vašimi blízkými. Můžete je buď sdílet přímo s ostatními uživateli Ente, koncově šifrované; nebo s pomocí veřejně přístupných odkazů.
|
||||
|
||||
Your encrypted data is stored across multiple locations, including a fall-out shelter in Paris. We take posterity seriously and make it easy to ensure that your memories outlive you.
|
||||
Vaše šifrovaná data jsou uložena na několika místech, včetně protiatomového krytu v Paříži. Bereme budoucnost vážně a usnadňujeme vám zajistit, aby vaše vzpomínky přežily vás samotné.
|
||||
|
||||
We are here to make the safest photos app ever, come join our journey!
|
||||
Jsme tu, abychom vytvořili nejbezpečnější aplikaci pro fotografie, jaká kdy byla. Přidejte se k nám!
|
||||
|
||||
✨ FEATURES
|
||||
- Original quality backups, because every pixel is important
|
||||
- Family plans, so you can share storage with your family
|
||||
- Shared folders, in case you want your partner to enjoy your "Camera" clicks
|
||||
- Album links, that can be protected with a password and set to expire
|
||||
- Ability to free up space, by removing files that have been safely backed up
|
||||
- Image editor, to add finishing touches
|
||||
- Favorite, hide and relive your memories, for they are precious
|
||||
- One-click import from Google, Apple, your hard drive and more
|
||||
- Dark theme, because your photos look good in it
|
||||
- 2FA, 3FA, biometric auth
|
||||
- and a LOT more!
|
||||
✨ VLASTNOSTI
|
||||
- Zálohování v původní kvalitě, protože každý pixel je důležitý
|
||||
- Rodinné plány, takže můžete sdílet úložiště s rodinou
|
||||
- Sdílené složky, aby i váš partner mohl obdivovat vaše fotografie
|
||||
- Odkazy na alba, které lze chránit heslem a nastavit jejich platnost
|
||||
- Možnost uvolnit místo odstraněním souborů, které byly bezpečně zálohovány
|
||||
- Editor obrázků, pro finální doladění detailů
|
||||
- Označte si své oblíbené fotografie, skryjte je a nebo je prožijte znovu pomocí vzpomínek
|
||||
- Import jedním kliknutím ze všech velkých poskytovatelů úložišť
|
||||
- Tmavý motiv, protože vaše fotografie v něm vypadají dobře
|
||||
- 2FA, 3FA, biometrické ověření
|
||||
- a ještě MNOHEM víc!
|
||||
|
||||
💲 PRICING
|
||||
We don't offer forever free plans, because it is important to us that we remain sustainable and withstand the test of time. Instead we offer affordable plans that you can freely share with your family. You can find more information at ente.io.
|
||||
💲 CENY
|
||||
Nenabízíme doživotní tarify, protože je pro nás důležité zůstat udržitelnými a obstát ve zkoušce času. Místo toho nabízíme cenově dostupné plány, které můžete svobodně sdílet se svou rodinou. Více informací najdete na ente.io.
|
||||
|
||||
🙋 SUPPORT
|
||||
We take pride in offering human support. If you are our paid customer, you can reach out to team@ente.io and expect a response from our team within 24 hours.
|
||||
🙋 PODPORA
|
||||
Jsme hrdí na to, že poskytujeme lidskou podporu. Pokud máte placený tarif, můžete se obrátit na team@ente.io a do 24 hodin od našeho týmu očekávat odpověď.
|
||||
@@ -1 +1 @@
|
||||
Encrypted photo storage - backup, organize and share your photos and videos
|
||||
Šifrované úložiště fotografií – zálohujte, organizujte a sdílejte své fotografie a videa
|
||||
@@ -1 +1 @@
|
||||
Ente Photos
|
||||
Ente Fotky
|
||||
@@ -981,7 +981,8 @@ class FilesDB with SqlDbBase {
|
||||
|
||||
// remove references for local files which are either already uploaded
|
||||
// or queued for upload but not yet uploaded
|
||||
Future<int> removeQueuedLocalFiles(Set<String> localIDs) async {
|
||||
// Remove queued local files that have duplicate uploaded entries with same localID
|
||||
Future<int> removeQueuedLocalFiles(Set<String> localIDs, int ownerID) async {
|
||||
if (localIDs.isEmpty) {
|
||||
_logger.finest("No local IDs provided for removal");
|
||||
return 0;
|
||||
@@ -990,54 +991,63 @@ class FilesDB with SqlDbBase {
|
||||
final db = await instance.sqliteAsyncDB;
|
||||
const batchSize = 10000;
|
||||
int totalRemoved = 0;
|
||||
|
||||
final localIDsList = localIDs.toList();
|
||||
|
||||
for (int i = 0; i < localIDsList.length; i += batchSize) {
|
||||
final endIndex = (i + batchSize > localIDsList.length)
|
||||
? localIDsList.length
|
||||
: i + batchSize;
|
||||
|
||||
final batch = localIDsList.sublist(i, endIndex);
|
||||
final placeholders = List.filled(batch.length, '?').join(',');
|
||||
final List<String> alreadyUploaded = [];
|
||||
// find localIDs that are already uploaded
|
||||
final result = await db.execute('''
|
||||
|
||||
// Find localIDs that already have uploaded entries
|
||||
final result = await db.execute(
|
||||
'''
|
||||
SELECT DISTINCT $columnLocalID
|
||||
FROM $filesTable
|
||||
WHERE $columnLocalID IN ($placeholders)
|
||||
WHERE
|
||||
$columnOwnerID = $ownerID
|
||||
AND $columnLocalID IN ($placeholders)
|
||||
AND ($columnUploadedFileID IS NOT NULL AND $columnUploadedFileID != -1)
|
||||
''');
|
||||
|
||||
for (final row in result) {
|
||||
alreadyUploaded.add(row[columnLocalID] as String);
|
||||
}
|
||||
final uploadedPlaceholders =
|
||||
alreadyUploaded.map((id) => "'$id'").join(',');
|
||||
final r = await db.execute(
|
||||
'''
|
||||
DELETE FROM $filesTable
|
||||
WHERE $columnLocalID IN ($uploadedPlaceholders)
|
||||
AND ($columnUploadedFileID IS NULL OR $columnUploadedFileID = -1)
|
||||
''',
|
||||
''',
|
||||
batch,
|
||||
);
|
||||
|
||||
if (r.isNotEmpty) {
|
||||
_logger.warning(
|
||||
"Batch ${(i ~/ batchSize) + 1}: Removed duplicate ${r.length} files",
|
||||
if (result.isNotEmpty) {
|
||||
final alreadyUploadedLocalIDs =
|
||||
result.map((row) => row[columnLocalID] as String).toList();
|
||||
final localIdPlaceholder =
|
||||
List.filled(alreadyUploadedLocalIDs.length, '?').join(',');
|
||||
|
||||
// Delete queued entries for localIDs that already have uploaded versions
|
||||
final deleteResult = await db.execute(
|
||||
'''
|
||||
DELETE FROM $filesTable
|
||||
WHERE $columnLocalID IN ($localIdPlaceholder)
|
||||
AND ($columnUploadedFileID IS NULL OR $columnUploadedFileID = -1)
|
||||
''',
|
||||
alreadyUploadedLocalIDs,
|
||||
);
|
||||
totalRemoved += r.length;
|
||||
|
||||
final removedCount =
|
||||
deleteResult.length; // or however your DB returns affected rows
|
||||
if (removedCount > 0) {
|
||||
_logger.warning(
|
||||
"Batch ${(i ~/ batchSize) + 1}: Removed $removedCount queued duplicates",
|
||||
);
|
||||
totalRemoved += removedCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalRemoved > 0) {
|
||||
_logger.warning(
|
||||
"Removed $totalRemoved potential dups for already queued local files",
|
||||
"Removed $totalRemoved queued files that had uploaded duplicates",
|
||||
);
|
||||
} else {
|
||||
_logger.finest("No duplicate id found for queued/uploaded files");
|
||||
_logger.finest("No queued duplicates found for uploaded files");
|
||||
}
|
||||
|
||||
return totalRemoved;
|
||||
}
|
||||
|
||||
@@ -1677,26 +1687,36 @@ class FilesDB with SqlDbBase {
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<EnteFile>> getAllFilesAfterDate({
|
||||
required FileType fileType,
|
||||
required DateTime beginDate,
|
||||
Future<List<EnteFile>> getStreamingEligibleVideoFiles({
|
||||
DateTime? beginDate,
|
||||
required int userID,
|
||||
bool onlyFilesWithLocalId = false,
|
||||
}) async {
|
||||
final db = await instance.sqliteAsyncDB;
|
||||
final results = await db.getAll(
|
||||
'''
|
||||
|
||||
String query = '''
|
||||
SELECT * FROM $filesTable
|
||||
WHERE $columnFileType = ?
|
||||
AND $columnCreationTime > ?
|
||||
AND $columnUploadedFileID != -1
|
||||
AND $columnOwnerID = $userID
|
||||
AND $columnLocalID IS NOT NULL
|
||||
AND ($columnUploadedFileID IS NOT NULL AND $columnUploadedFileID != -1)
|
||||
AND $columnOwnerID = ?
|
||||
AND ($columnFileSize IS NOT NULL AND $columnFileSize <= 524288000)
|
||||
AND ($columnDuration IS NOT NULL AND ($columnDuration <= 60 AND $columnDuration > 0))
|
||||
ORDER BY $columnCreationTime DESC
|
||||
''',
|
||||
[getInt(fileType), beginDate.microsecondsSinceEpoch],
|
||||
);
|
||||
''';
|
||||
|
||||
final List<Object> queryArgs = [getInt(FileType.video), userID];
|
||||
|
||||
if (beginDate != null) {
|
||||
query += ' AND $columnCreationTime > ?';
|
||||
queryArgs.add(beginDate.microsecondsSinceEpoch);
|
||||
}
|
||||
|
||||
if (onlyFilesWithLocalId) {
|
||||
query += ' AND $columnLocalID IS NOT NULL';
|
||||
}
|
||||
|
||||
query += ' ORDER BY $columnCreationTime DESC';
|
||||
|
||||
final results = await db.getAll(query, queryArgs);
|
||||
return convertToFiles(results);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import "dart:io" show File;
|
||||
import "dart:typed_data" show Float32List;
|
||||
|
||||
import "package:flutter_rust_bridge/flutter_rust_bridge.dart" show Uint64List;
|
||||
@@ -8,11 +7,13 @@ import "package:path_provider/path_provider.dart";
|
||||
import "package:photos/models/ml/vector.dart";
|
||||
import "package:photos/services/machine_learning/semantic_search/query_result.dart";
|
||||
import "package:photos/src/rust/api/usearch_api.dart";
|
||||
import "package:shared_preferences/shared_preferences.dart";
|
||||
|
||||
class ClipVectorDB {
|
||||
static final Logger _logger = Logger("ClipVectorDB");
|
||||
|
||||
static const _databaseName = "ente.ml.vectordb.clip";
|
||||
static const _kMigrationKey = "clip_vector_migration";
|
||||
|
||||
static final BigInt _embeddingDimension = BigInt.from(512);
|
||||
|
||||
@@ -51,10 +52,9 @@ class ClipVectorDB {
|
||||
Future<bool> checkIfMigrationDone() async {
|
||||
if (_migrationDone != null) return _migrationDone!;
|
||||
_logger.info("Checking if ClipVectorDB migration has run");
|
||||
final documentsDirectory = await getApplicationDocumentsDirectory();
|
||||
final migrationFlagFile =
|
||||
File(join(documentsDirectory.path, 'clip_vector_migration_done'));
|
||||
if (await migrationFlagFile.exists()) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final migrationDone = prefs.getBool(_kMigrationKey) ?? false;
|
||||
if (migrationDone) {
|
||||
_logger.info("ClipVectorDB migration already done");
|
||||
_migrationDone = true;
|
||||
return _migrationDone!;
|
||||
@@ -67,10 +67,8 @@ class ClipVectorDB {
|
||||
|
||||
Future<void> setMigrationDone() async {
|
||||
_logger.info("Setting ClipVectorDB migration done");
|
||||
final documentsDirectory = await getApplicationDocumentsDirectory();
|
||||
final migrationFlagFile =
|
||||
File(join(documentsDirectory.path, 'clip_vector_migration_done'));
|
||||
await migrationFlagFile.create(recursive: true);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_kMigrationKey, true);
|
||||
_migrationDone = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,13 @@ class UploadLocksDB {
|
||||
columnCreatedAt: "created_at",
|
||||
);
|
||||
|
||||
static const _streamQueueTable = (
|
||||
table: "stream_queue",
|
||||
columnUploadedFileID: "uploaded_file_id",
|
||||
columnQueueType: "queue_type", // 'create' or 'recreate'
|
||||
columnCreatedAt: "created_at",
|
||||
);
|
||||
|
||||
static final initializationScript = [
|
||||
..._createUploadLocksTable(),
|
||||
];
|
||||
@@ -134,18 +141,26 @@ class UploadLocksDB {
|
||||
${_streamUploadErrorTable.columnCreatedAt} INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
)
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE IF NOT EXISTS ${_streamQueueTable.table} (
|
||||
${_streamQueueTable.columnUploadedFileID} INTEGER PRIMARY KEY,
|
||||
${_streamQueueTable.columnQueueType} TEXT NOT NULL,
|
||||
${_streamQueueTable.columnCreatedAt} INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
)
|
||||
''',
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> clearTable() async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
await db.delete(_uploadLocksTable.table);
|
||||
await db.delete(_trackUploadTable.table);
|
||||
await db.delete(_partsTable.table);
|
||||
await db.delete(_streamQueueTable.table);
|
||||
}
|
||||
|
||||
Future<void> acquireLock(String id, String owner, int time) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
final row = <String, dynamic>{};
|
||||
row[_uploadLocksTable.columnID] = id;
|
||||
row[_uploadLocksTable.columnOwner] = owner;
|
||||
@@ -158,7 +173,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<String> getLockData(String id) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
_uploadLocksTable.table,
|
||||
where: '${_uploadLocksTable.columnID} = ?',
|
||||
@@ -175,7 +190,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<bool> isLocked(String id, String owner) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
_uploadLocksTable.table,
|
||||
where:
|
||||
@@ -186,7 +201,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<int> releaseLock(String id, String owner) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
return db.delete(
|
||||
_uploadLocksTable.table,
|
||||
where:
|
||||
@@ -196,7 +211,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<int> releaseLocksAcquiredByOwnerBefore(String owner, int time) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
return db.delete(
|
||||
_uploadLocksTable.table,
|
||||
where:
|
||||
@@ -206,7 +221,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<int> releaseAllLocksAcquiredBefore(int time) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
return db.delete(
|
||||
_uploadLocksTable.table,
|
||||
where: '${_uploadLocksTable.columnTime} < ?',
|
||||
@@ -220,7 +235,7 @@ class UploadLocksDB {
|
||||
String fileHash,
|
||||
int collectionID,
|
||||
) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
|
||||
final rows = await db.query(
|
||||
_trackUploadTable.table,
|
||||
@@ -247,7 +262,7 @@ class UploadLocksDB {
|
||||
String fileHash,
|
||||
int collectionID,
|
||||
) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
await db.update(
|
||||
_trackUploadTable.table,
|
||||
{
|
||||
@@ -270,7 +285,7 @@ class UploadLocksDB {
|
||||
String fileHash,
|
||||
int collectionID,
|
||||
) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
_trackUploadTable.table,
|
||||
where: '${_trackUploadTable.columnLocalID} = ?'
|
||||
@@ -328,7 +343,7 @@ class UploadLocksDB {
|
||||
int uploadedFileID,
|
||||
String errorMessage,
|
||||
) async {
|
||||
final db = await UploadLocksDB.instance.database;
|
||||
final db = await database;
|
||||
|
||||
await db.insert(
|
||||
_streamUploadErrorTable.table,
|
||||
@@ -346,7 +361,7 @@ class UploadLocksDB {
|
||||
int uploadedFileID,
|
||||
String errorMessage,
|
||||
) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
await db.update(
|
||||
_streamUploadErrorTable.table,
|
||||
{
|
||||
@@ -360,7 +375,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<int> deleteStreamUploadErrorEntry(int uploadedFileID) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
return await db.delete(
|
||||
_streamUploadErrorTable.table,
|
||||
where: '${_streamUploadErrorTable.columnUploadedFileID} = ?',
|
||||
@@ -369,7 +384,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<Map<int, String>> getStreamUploadError() {
|
||||
return instance.database.then((db) async {
|
||||
return database.then((db) async {
|
||||
final rows = await db.query(
|
||||
_streamUploadErrorTable.table,
|
||||
columns: [
|
||||
@@ -398,7 +413,7 @@ class UploadLocksDB {
|
||||
String keyNonce, {
|
||||
required int partSize,
|
||||
}) async {
|
||||
final db = await UploadLocksDB.instance.database;
|
||||
final db = await database;
|
||||
final objectKey = urls.objectKey;
|
||||
|
||||
await db.insert(
|
||||
@@ -441,7 +456,7 @@ class UploadLocksDB {
|
||||
int partNumber,
|
||||
String etag,
|
||||
) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
await db.update(
|
||||
_partsTable.table,
|
||||
{
|
||||
@@ -458,7 +473,7 @@ class UploadLocksDB {
|
||||
String objectKey,
|
||||
MultipartStatus status,
|
||||
) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
await db.update(
|
||||
_trackUploadTable.table,
|
||||
{
|
||||
@@ -472,7 +487,7 @@ class UploadLocksDB {
|
||||
Future<int> deleteMultipartTrack(
|
||||
String localId,
|
||||
) async {
|
||||
final db = await instance.database;
|
||||
final db = await database;
|
||||
return await db.delete(
|
||||
_trackUploadTable.table,
|
||||
where: '${_trackUploadTable.columnLocalID} = ?',
|
||||
@@ -482,7 +497,7 @@ class UploadLocksDB {
|
||||
|
||||
// getFileNameToLastAttemptedAtMap returns a map of encrypted file name to last attempted at time
|
||||
Future<Map<String, int>> getFileNameToLastAttemptedAtMap() {
|
||||
return instance.database.then((db) async {
|
||||
return database.then((db) async {
|
||||
final rows = await db.query(
|
||||
_trackUploadTable.table,
|
||||
columns: [
|
||||
@@ -504,7 +519,7 @@ class UploadLocksDB {
|
||||
String fileHash,
|
||||
int collectionID,
|
||||
) {
|
||||
return instance.database.then((db) async {
|
||||
return database.then((db) async {
|
||||
final rows = await db.query(
|
||||
_trackUploadTable.table,
|
||||
where: '${_trackUploadTable.columnLocalID} = ?'
|
||||
@@ -519,4 +534,56 @@ class UploadLocksDB {
|
||||
return row[_trackUploadTable.columnEncryptedFileName] as String;
|
||||
});
|
||||
}
|
||||
|
||||
// Stream Queue Management Methods
|
||||
Future<void> addToStreamQueue(
|
||||
int uploadedFileID,
|
||||
String queueType, // 'create' or 'recreate'
|
||||
) async {
|
||||
final db = await database;
|
||||
await db.insert(
|
||||
_streamQueueTable.table,
|
||||
{
|
||||
_streamQueueTable.columnUploadedFileID: uploadedFileID,
|
||||
_streamQueueTable.columnQueueType: queueType,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeFromStreamQueue(int uploadedFileID) async {
|
||||
final db = await database;
|
||||
await db.delete(
|
||||
_streamQueueTable.table,
|
||||
where: '${_streamQueueTable.columnUploadedFileID} = ?',
|
||||
whereArgs: [uploadedFileID],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<int, String>> getStreamQueue() async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
_streamQueueTable.table,
|
||||
columns: [
|
||||
_streamQueueTable.columnUploadedFileID,
|
||||
_streamQueueTable.columnQueueType,
|
||||
],
|
||||
);
|
||||
final map = <int, String>{};
|
||||
for (final row in rows) {
|
||||
map[row[_streamQueueTable.columnUploadedFileID] as int] =
|
||||
row[_streamQueueTable.columnQueueType] as String;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
Future<bool> isInStreamQueue(int uploadedFileID) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
_streamQueueTable.table,
|
||||
where: '${_streamQueueTable.columnUploadedFileID} = ?',
|
||||
whereArgs: [uploadedFileID],
|
||||
);
|
||||
return rows.isNotEmpty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import "package:photos/events/event.dart";
|
||||
import "package:photos/models/preview/preview_item_status.dart";
|
||||
|
||||
class VideoPreviewStateChangedEvent extends Event {
|
||||
final int fileId;
|
||||
final PreviewItemStatus status;
|
||||
|
||||
VideoPreviewStateChangedEvent(this.fileId, this.status);
|
||||
|
||||
@override
|
||||
String get reason => '$runtimeType: fileId=$fileId, status=$status';
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1827,5 +1827,124 @@
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"videosProcessed": "Videos processed",
|
||||
"totalVideos": "Total videos",
|
||||
"skippedVideos": "Skipped videos",
|
||||
"videoStreamingDescriptionLine1": "Play videos instantly on any device.",
|
||||
"videoStreamingDescriptionLine2": "Enable to process video streams on this device.",
|
||||
"videoStreamingNote": "Only videos from last 60 days and under 1 minute are processed on this device. For older/longer videos, enable streaming in the desktop app.",
|
||||
"createStream": "Create stream",
|
||||
"recreateStream": "Recreate stream",
|
||||
"addedToStreamCreationQueue": "Added to stream creation queue",
|
||||
"addedToStreamRecreationQueue": "Added to stream recreation queue",
|
||||
"videoPreviewAlreadyExists": "Video preview already exists",
|
||||
"videoAlreadyInQueue": "Video file already present in the queue",
|
||||
"addedToQueue": "Added to queue",
|
||||
"creatingStream": "Creating stream",
|
||||
"similarImages": "Similar images",
|
||||
"deletingProgress": "Deleting... {progress}",
|
||||
"@deletingProgress": {
|
||||
"placeholders": {
|
||||
"progress": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"findSimilarImages": "Find similar images",
|
||||
"noSimilarImagesFound": "No similar images found",
|
||||
"yourPhotosLookUnique": "Your photos look unique",
|
||||
"similarGroupsFound": "{count, plural, =1{{count} group found} other{{count} groups found}}",
|
||||
"@similarGroupsFound": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reviewAndRemoveSimilarImages": "Review and remove similar images",
|
||||
"deletePhotosWithSize": "Delete {count} photos ({size})",
|
||||
"@deletePhotosWithSize": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "String"
|
||||
},
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionOptions": "Selection options",
|
||||
"selectExactWithCount": "Exactly similar ({count})",
|
||||
"@selectExactWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectExact": "Select exact",
|
||||
"selectSimilarWithCount": "Mostly similar ({count})",
|
||||
"@selectSimilarWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilar": "Select similar",
|
||||
"selectAllWithCount": "All similarities ({count})",
|
||||
"@selectAllWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilarImagesTitle": "Select similar images",
|
||||
"chooseSimilarImagesToSelect": "Select images based on their visual similarity",
|
||||
"clearSelection": "Clear selection",
|
||||
"similarImagesCount": "{count} similar images",
|
||||
"@similarImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteWithCount": "Delete ({count})",
|
||||
"@deleteWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteFiles": "Delete files",
|
||||
"areYouSureDeleteFiles": "Are you sure you want to delete these files?",
|
||||
"greatJob": "Great job!",
|
||||
"cleanedUpSimilarImages": "You cleaned up {count, plural, =1{{count} similar image} other{{count} similar images}} and freed up {size}",
|
||||
"@cleanedUpSimilarImages": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"size": "Size",
|
||||
"similarity": "Similarity",
|
||||
"analyzingPhotosLocally": "Analyzing your photos locally",
|
||||
"lookingForVisualSimilarities": "Looking for visual similarities",
|
||||
"comparingImageDetails": "Comparing image details",
|
||||
"findingSimilarImages": "Finding similar images",
|
||||
"almostDone": "Almost done",
|
||||
"processingLocally": "Processing locally",
|
||||
"useMLToFindSimilarImages": "Review and remove images that look similar to each other.",
|
||||
"all": "All",
|
||||
"similar": "Similar",
|
||||
"identical": "Identical",
|
||||
"nothingHereTryAnotherFilter": "Nothing here, try another filter! 👀"
|
||||
}
|
||||
|
||||
@@ -1776,5 +1776,56 @@
|
||||
"same": "Igual",
|
||||
"different": "Diferente",
|
||||
"sameperson": "la misma persona?",
|
||||
"indexingPausedStatusDescription": "La indexación está pausada. Se reanudará automáticamente cuando el dispositivo esté listo. El dispositivo se considera listo cuando su nivel de batería, la salud de la batería y temperatura están en un rango saludable."
|
||||
"cLTitle1": "Editor avanzado de imágenes",
|
||||
"cLDesc1": "Estamos lanzando un nuevo y avanzado editor de imágenes que añade más marcos de recorte, preajustes de filtros para edición rápida, opciones de ajuste finas incluyendo saturación, contraste, brillo, temperatura y mucho más. El nuevo editor también incluye la capacidad de dibujar en tus fotos y añadir emojis como pegatinas.",
|
||||
"cLTitle2": "Álbumes Inteligentes",
|
||||
"cLDesc2": "Ahora puedes añadir automáticamente fotos de personas seleccionadas a cualquier álbum. Solo tienes que ir al álbum, y seleccionar \"Agregar personas automáticamente\" del menú desbordante. Si se utiliza junto con el álbum compartido, puedes compartir fotos con cero clics.",
|
||||
"cLTitle3": "Galería mejorada",
|
||||
"cLDesc3": "Hemos añadido la capacidad de agrupar tu galería por semanas, meses y años. Ahora puedes personalizar tu galería exactamente como quieras con estas nuevas opciones de agrupación, junto con rejillas personalizadas",
|
||||
"cLTitle4": "Desplazamiento más rápido",
|
||||
"cLDesc4": "Junto con un montón de mejoras bajo el capó para mejorar la experiencia del desplazamiento de la galería también hemos rediseñado la barra de desplazamiento para mostrar los marcadores, permitiéndote saltar rápidamente a través de la línea de tiempo.",
|
||||
"indexingPausedStatusDescription": "La indexación está pausada. Se reanudará automáticamente cuando el dispositivo esté listo. El dispositivo se considera listo cuando su nivel de batería, la salud de la batería y temperatura están en un rango saludable.",
|
||||
"thisWeek": "Esta semana",
|
||||
"lastWeek": "Semana pasada",
|
||||
"thisMonth": "Este mes",
|
||||
"thisYear": "Este año",
|
||||
"groupBy": "Agrupar por",
|
||||
"faceThumbnailGenerationFailed": "No se pueden generar las miniaturas de cara",
|
||||
"fileAnalysisFailed": "No se puede analizar el archivo",
|
||||
"editAutoAddPeople": "Editar agregar personas automáticamente",
|
||||
"autoAddPeople": "Agregar personas automáticamente",
|
||||
"autoAddToAlbum": "Añadir al álbum automáticamente",
|
||||
"shouldRemoveFilesSmartAlbumsDesc": "¿Deben eliminarse los archivos relacionados con la persona previamente seleccionada en los álbumes inteligentes?",
|
||||
"addingPhotos": "Añadiendo fotos",
|
||||
"gettingReady": "Preparándose",
|
||||
"addSomePhotosDesc1": "Añadir algunas fotos o elegir ",
|
||||
"addSomePhotosDesc2": "caras familiares",
|
||||
"addSomePhotosDesc3": "\npara comenzar con",
|
||||
"ignorePerson": "Ignorar persona",
|
||||
"mixedGrouping": "¿Grupo mixto?",
|
||||
"analysis": "Análisis",
|
||||
"doesGroupContainMultiplePeople": "¿Esta agrupación contiene varias personas?",
|
||||
"automaticallyAnalyzeAndSplitGrouping": "Analizaremos automáticamente la agrupación para determinar si hay varias personas presentes, y separarlas de nuevo. Esto puede tardar unos segundos.",
|
||||
"layout": "Disposición",
|
||||
"day": "Día",
|
||||
"peopleAutoAddDesc": "Selecciona las personas que quieres añadir automáticamente al álbum",
|
||||
"undo": "Deshacer",
|
||||
"redo": "Rehacer",
|
||||
"filter": "Filtro",
|
||||
"adjust": "Ajustar",
|
||||
"draw": "Dibujar",
|
||||
"sticker": "Pegatina",
|
||||
"brushColor": "Color del pincel",
|
||||
"font": "Fuente",
|
||||
"background": "Fondo",
|
||||
"align": "Alinear",
|
||||
"addedToAlbums": "{count, plural, one {}=1{Añadido con éxito a 1 álbum} other{Añadido con éxito a {count} álbumes}}",
|
||||
"@addedToAlbums": {
|
||||
"description": "Message shown when items are added to albums",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -347,9 +347,11 @@
|
||||
"deletePhotos": "Radera foton",
|
||||
"inviteToEnte": "Bjud in till Ente",
|
||||
"removePublicLink": "Ta bort publik länk",
|
||||
"disableLinkMessage": "Detta kommer att ta bort den publika länken för att komma åt \"{albumName}\".",
|
||||
"sharing": "Delar...",
|
||||
"youCannotShareWithYourself": "Du kan inte dela med dig själv",
|
||||
"archive": "Arkiv",
|
||||
"createAlbumActionHint": "Långtryck för att välja foton och klicka på + för att skapa ett album",
|
||||
"importing": "Importerar....",
|
||||
"failedToLoadAlbums": "Det gick inte att läsa in album",
|
||||
"hidden": "Dold",
|
||||
@@ -360,16 +362,96 @@
|
||||
"videoSmallCase": "video",
|
||||
"photoSmallCase": "foto",
|
||||
"singleFileDeleteHighlight": "Det kommer att tas bort från alla album.",
|
||||
"singleFileInBothLocalAndRemote": "Denna {fileType} finns i både Ente och din enhet.",
|
||||
"singleFileInRemoteOnly": "Denna {fileType} kommer att raderas från Ente.",
|
||||
"singleFileDeleteFromDevice": "Denna {fileType} kommer att raderas från din enhet.",
|
||||
"deleteFromEnte": "Radera från ente",
|
||||
"yesDelete": "Ja, radera",
|
||||
"movedToTrash": "Flyttad till papperskorgen",
|
||||
"deleteFromDevice": "Radera från enhet",
|
||||
"deleteFromBoth": "",
|
||||
"newAlbum": "Nytt album",
|
||||
"albums": "Album",
|
||||
"memoryCount": "{count, plural, =0{inga minnen} one{{formattedCount} minne} other{{formattedCount} minnen}}",
|
||||
"@memoryCount": {
|
||||
"description": "The text to display the number of memories",
|
||||
"type": "text",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"example": "1",
|
||||
"type": "int"
|
||||
},
|
||||
"formattedCount": {
|
||||
"type": "String",
|
||||
"example": "11.513, 11,511"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectedPhotos": "{count} markerade",
|
||||
"@selectedPhotos": {
|
||||
"description": "Display the number of selected photos",
|
||||
"type": "text",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"example": "5",
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectedPhotosWithYours": "{count} markerade ({yourCount} din)",
|
||||
"@selectedPhotosWithYours": {
|
||||
"description": "Display the number of selected photos, including the number of selected photos owned by the user",
|
||||
"type": "text",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"example": "12",
|
||||
"type": "int"
|
||||
},
|
||||
"yourCount": {
|
||||
"example": "2",
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"advancedSettings": "Avancerad",
|
||||
"@advancedSettings": {
|
||||
"description": "The text to display in the advanced settings section"
|
||||
},
|
||||
"photoGridSize": "Storlek på bildrutnät",
|
||||
"manageDeviceStorage": "Hantera enhetscache",
|
||||
"manageDeviceStorageDesc": "Granska och rensa lokal cachelagring.",
|
||||
"machineLearning": "Maskininlärning",
|
||||
"mlConsent": "Aktivera maskininlärning",
|
||||
"mlConsentTitle": "Aktivera maskininlärning?",
|
||||
"mlConsentDescription": "Om du aktiverar maskininlärning, kommer Ente extrahera information som ansiktsgeometri från filer, inklusive de som delas med dig.\n\nDetta kommer att hända på din enhet, och all genererad biometrisk information kommer att helsträckskrypteras.",
|
||||
"mlConsentPrivacy": "Klicka här för mer information om denna funktion i vår integritetspolicy",
|
||||
"mlConsentConfirmation": "Jag förstår och vill aktivera maskininlärning",
|
||||
"magicSearch": "Magisk sökning",
|
||||
"discover": "Upptäck",
|
||||
"@discover": {
|
||||
"description": "The text to display for the discover section under which we show receipts, screenshots, sunsets, greenery, etc."
|
||||
},
|
||||
"discover_identity": "Identitet",
|
||||
"discover_screenshots": "Skärmdumpar",
|
||||
"discover_receipts": "Kvitton",
|
||||
"discover_notes": "Anteckningar",
|
||||
"discover_memes": "Memes",
|
||||
"discover_visiting_cards": "Besökskort",
|
||||
"discover_babies": "Barn",
|
||||
"discover_pets": "Husdjur",
|
||||
"discover_selfies": "Selfies",
|
||||
"discover_wallpapers": "Bakgrundsbilder",
|
||||
"discover_food": "Mat",
|
||||
"discover_celebrations": "Firanden",
|
||||
"discover_sunset": "Solnedgång",
|
||||
"discover_hills": "Berg",
|
||||
"discover_greenery": "Grönt landskap",
|
||||
"mlIndexingDescription": "Observera att maskininlärning kommer att resultera i en högre bandbredd och batterianvändning tills alla objekt är indexerade. Överväg att använda skrivbordsappen för snabbare indexering, alla resultat kommer att synkroniseras automatiskt.",
|
||||
"loadingModel": "Laddar ner modeller...",
|
||||
"waitingForWifi": "Väntar på WiFi...",
|
||||
"status": "Status",
|
||||
"indexedItems": "Indexerade objekt",
|
||||
"pendingItems": "Väntande objekt",
|
||||
"clearIndexes": "Rensa index",
|
||||
"selectFoldersForBackup": "Välj mappar för säkerhetskopiering",
|
||||
"selectedFoldersWillBeEncryptedAndBackedUp": "Valda mappar kommer att krypteras och säkerhetskopieras",
|
||||
@@ -398,19 +480,47 @@
|
||||
"yearsAgo": "{count, plural, one{{count} år sedan} other{{count} år sedan}}",
|
||||
"backupSettings": "Säkerhetskopieringsinställningar",
|
||||
"backupStatus": "Säkerhetskopieringsstatus",
|
||||
"backupStatusDescription": "Objekt som har säkerhetskopierats kommer att visas här",
|
||||
"backupOverMobileData": "Säkerhetskopiera via mobildata",
|
||||
"backupVideos": "Säkerhetskopiera videor",
|
||||
"disableAutoLock": "Inaktivera automatisk låsning",
|
||||
"deviceLockExplanation": "Inaktivera enhetens skärmlås när Ente är i förgrunden och säkerhetskopiering pågår. Detta är normalt inte nödvändigt, men kan hjälpa stora uppladdningar och initial import av stora bibliotek slutföra snabbare.",
|
||||
"about": "Om",
|
||||
"weAreOpenSource": "Vi har öppen källkod!",
|
||||
"privacy": "Sekretess",
|
||||
"terms": "Villkor",
|
||||
"checkForUpdates": "Sök efter uppdateringar",
|
||||
"checkStatus": "Kontrollera status",
|
||||
"checking": "Kontrollerar...",
|
||||
"youAreOnTheLatestVersion": "Du är på den senaste versionen",
|
||||
"account": "Konto",
|
||||
"manageSubscription": "Hantera prenumeration",
|
||||
"authToChangeYourEmail": "Autentisera för att ändra din e-postadress",
|
||||
"changePassword": "Ändra lösenord",
|
||||
"authToChangeYourPassword": "Autentisera för att ändra ditt lösenord",
|
||||
"emailVerificationToggle": "E-postverifiering",
|
||||
"exportYourData": "Exportera din data",
|
||||
"logout": "Logga ut",
|
||||
"authToInitiateAccountDeletion": "Vänligen autentisera för att initiera borttagning av konto",
|
||||
"areYouSureYouWantToLogout": "Är du säker på att du vill logga ut?",
|
||||
"yesLogout": "Ja, logga ut",
|
||||
"aNewVersionOfEnteIsAvailable": "En ny version av Ente är tillgänglig.",
|
||||
"update": "Uppdatera",
|
||||
"installManually": "Installera manuellt",
|
||||
"criticalUpdateAvailable": "Kritisk uppdatering tillgänglig",
|
||||
"updateAvailable": "Uppdatering tillgänglig",
|
||||
"ignoreUpdate": "Ignorera",
|
||||
"downloading": "Laddar ner...",
|
||||
"cannotDeleteSharedFiles": "Kan inte ta bort delade filer",
|
||||
"theDownloadCouldNotBeCompleted": "Nedladdningen kunde inte slutföras",
|
||||
"retry": "Försök igen",
|
||||
"backedUpFolders": "Säkerhetskopiera mappar",
|
||||
"backup": "Säkerhetskopiera",
|
||||
"freeUpDeviceSpace": "Frigör enhetens lagringsutrymme",
|
||||
"freeUpDeviceSpaceDesc": "Spara utrymme på din enhet genom att rensa filer som redan har säkerhetskopierats.",
|
||||
"allClear": "✨ Allt klart",
|
||||
"noDeviceThatCanBeDeleted": "Du har inga filer på denna enhet som kan tas bort",
|
||||
"removeDuplicates": "Ta bort dubbletter",
|
||||
"viewActiveSessions": "Visa aktiva sessioner",
|
||||
"no": "Nej",
|
||||
"yes": "Ja",
|
||||
|
||||
@@ -280,7 +280,6 @@ Future<void> _init(bool isBackground, {String via = ''}) async {
|
||||
}).ignore();
|
||||
}
|
||||
_logger.info("PushService/HomeWidget done $tlog");
|
||||
VideoPreviewService.instance.init(preferences);
|
||||
unawaited(SemanticSearchService.instance.init());
|
||||
unawaited(MLService.instance.init());
|
||||
await PersonService.init(
|
||||
|
||||
103
mobile/apps/photos/lib/models/feed/feed_models.dart
Normal file
103
mobile/apps/photos/lib/models/feed/feed_models.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
class FeedUser {
|
||||
final String id;
|
||||
final String name;
|
||||
final String avatarUrl;
|
||||
|
||||
const FeedUser({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.avatarUrl,
|
||||
});
|
||||
}
|
||||
|
||||
class FeedPhoto {
|
||||
final String id;
|
||||
final String url;
|
||||
final String? description;
|
||||
|
||||
const FeedPhoto({
|
||||
required this.id,
|
||||
required this.url,
|
||||
this.description,
|
||||
});
|
||||
}
|
||||
|
||||
enum FeedItemType {
|
||||
memory,
|
||||
album,
|
||||
photos,
|
||||
video,
|
||||
}
|
||||
|
||||
class FeedItem {
|
||||
final String id;
|
||||
final FeedUser user;
|
||||
final FeedItemType type;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final List<FeedPhoto> photos;
|
||||
final bool isLiked;
|
||||
final int likeCount;
|
||||
final String timeAgo;
|
||||
final bool isVideo;
|
||||
|
||||
const FeedItem({
|
||||
required this.id,
|
||||
required this.user,
|
||||
required this.type,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.photos,
|
||||
this.isLiked = false,
|
||||
this.likeCount = 0,
|
||||
required this.timeAgo,
|
||||
this.isVideo = false,
|
||||
});
|
||||
|
||||
FeedItem copyWith({
|
||||
bool? isLiked,
|
||||
int? likeCount,
|
||||
}) {
|
||||
return FeedItem(
|
||||
id: id,
|
||||
user: user,
|
||||
type: type,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
photos: photos,
|
||||
isLiked: isLiked ?? this.isLiked,
|
||||
likeCount: likeCount ?? this.likeCount,
|
||||
timeAgo: timeAgo,
|
||||
isVideo: isVideo,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationItem {
|
||||
final String id;
|
||||
final FeedUser user;
|
||||
final String action;
|
||||
final String timeAgo;
|
||||
final FeedPhoto? photo;
|
||||
final bool isRead;
|
||||
|
||||
const NotificationItem({
|
||||
required this.id,
|
||||
required this.user,
|
||||
required this.action,
|
||||
required this.timeAgo,
|
||||
this.photo,
|
||||
this.isRead = false,
|
||||
});
|
||||
|
||||
NotificationItem copyWith({bool? isRead}) {
|
||||
return NotificationItem(
|
||||
id: id,
|
||||
user: user,
|
||||
action: action,
|
||||
timeAgo: timeAgo,
|
||||
photo: photo,
|
||||
isRead: isRead ?? this.isRead,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// https://discover.ente.io/v1.json
|
||||
class Prompt {
|
||||
final String id;
|
||||
final int position;
|
||||
@@ -8,17 +9,6 @@ class Prompt {
|
||||
final bool showVideo;
|
||||
final bool recentFirst;
|
||||
|
||||
Prompt({
|
||||
String? id,
|
||||
this.position = 0,
|
||||
required this.query,
|
||||
required this.minScore,
|
||||
required this.minSize,
|
||||
required this.title,
|
||||
this.showVideo = true,
|
||||
this.recentFirst = false,
|
||||
}) : id = id ?? title;
|
||||
|
||||
// fromJson
|
||||
Prompt.fromJson(Map<String, dynamic> json)
|
||||
: id = json['id'] ?? json['title'],
|
||||
|
||||
@@ -15,7 +15,6 @@ class SimilarFiles {
|
||||
|
||||
int get totalSize => files.fold(0, (sum, file) => sum + (file.fileSize ?? 0));
|
||||
|
||||
// TODO: lau: check if we're not using this wrong
|
||||
bool get isEmpty => files.isEmpty;
|
||||
|
||||
int get length => files.length;
|
||||
|
||||
@@ -6,6 +6,7 @@ import "package:package_info_plus/package_info_plus.dart";
|
||||
import "package:photos/gateways/entity_gw.dart";
|
||||
import "package:photos/module/download/manager.dart";
|
||||
import "package:photos/services/account/billing_service.dart";
|
||||
import "package:photos/services/collections_service.dart";
|
||||
import "package:photos/services/entity_service.dart";
|
||||
import "package:photos/services/filedata/filedata_service.dart";
|
||||
import "package:photos/services/location_service.dart";
|
||||
@@ -183,3 +184,9 @@ SmartAlbumsService get smartAlbumsService {
|
||||
_smartAlbumsService ??= SmartAlbumsService();
|
||||
return _smartAlbumsService!;
|
||||
}
|
||||
|
||||
CollectionsService? _collectionsService;
|
||||
CollectionsService get collectionsService {
|
||||
_collectionsService ??= CollectionsService.instance;
|
||||
return _collectionsService!;
|
||||
}
|
||||
|
||||
289
mobile/apps/photos/lib/services/feed/feed_data_service.dart
Normal file
289
mobile/apps/photos/lib/services/feed/feed_data_service.dart
Normal file
@@ -0,0 +1,289 @@
|
||||
import 'package:photos/models/feed/feed_models.dart';
|
||||
|
||||
class FeedDataService {
|
||||
static const List<FeedUser> _mockUsers = [
|
||||
FeedUser(
|
||||
id: "1",
|
||||
name: "Bob",
|
||||
avatarUrl: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face",
|
||||
),
|
||||
FeedUser(
|
||||
id: "2",
|
||||
name: "Alice",
|
||||
avatarUrl: "https://images.unsplash.com/photo-1494790108755-2616b09c7bec?w=150&h=150&fit=crop&crop=face",
|
||||
),
|
||||
FeedUser(
|
||||
id: "3",
|
||||
name: "Charlie",
|
||||
avatarUrl: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face",
|
||||
),
|
||||
FeedUser(
|
||||
id: "4",
|
||||
name: "Diana",
|
||||
avatarUrl: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face",
|
||||
),
|
||||
];
|
||||
|
||||
static List<FeedItem> getMockFeedItems() {
|
||||
return [
|
||||
FeedItem(
|
||||
id: "1",
|
||||
user: _mockUsers[0],
|
||||
type: FeedItemType.memory,
|
||||
title: "Trip to paris",
|
||||
subtitle: "shared a memory",
|
||||
timeAgo: "2h ago",
|
||||
photos: [
|
||||
const FeedPhoto(
|
||||
id: "1",
|
||||
url: "https://images.unsplash.com/photo-1499856871958-5b9627545d1a?w=400&h=600&fit=crop&crop=center",
|
||||
description: "Beautiful archway in Paris with a silhouette of a person",
|
||||
),
|
||||
const FeedPhoto(
|
||||
id: "1a",
|
||||
url: "https://images.unsplash.com/photo-1502602898536-47ad22581b52?w=400&h=600&fit=crop",
|
||||
description: "Eiffel Tower at sunset",
|
||||
),
|
||||
const FeedPhoto(
|
||||
id: "1b",
|
||||
url: "https://images.unsplash.com/photo-1471623643120-e6ccc3452a4e?w=400&h=600&fit=crop",
|
||||
description: "Paris street with classic architecture",
|
||||
),
|
||||
const FeedPhoto(
|
||||
id: "1c",
|
||||
url: "https://images.unsplash.com/photo-1549144511-f099e773c147?w=400&h=600&fit=crop",
|
||||
description: "Seine river with Notre Dame",
|
||||
),
|
||||
],
|
||||
),
|
||||
FeedItem(
|
||||
id: "2",
|
||||
user: _mockUsers[0],
|
||||
type: FeedItemType.photos,
|
||||
title: "Maldives",
|
||||
subtitle: "shared 3 photos",
|
||||
timeAgo: "4h ago",
|
||||
likeCount: 12,
|
||||
photos: [
|
||||
const FeedPhoto(
|
||||
id: "2",
|
||||
url: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=600&fit=crop",
|
||||
description: "Woman sitting on wooden walkway overlooking tropical landscape",
|
||||
),
|
||||
const FeedPhoto(
|
||||
id: "3",
|
||||
url: "https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=400&h=600&fit=crop",
|
||||
description: "Tropical beach with crystal clear water",
|
||||
),
|
||||
const FeedPhoto(
|
||||
id: "4",
|
||||
url: "https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=400&h=600&fit=crop",
|
||||
description: "Overwater bungalows in Maldives",
|
||||
),
|
||||
],
|
||||
),
|
||||
FeedItem(
|
||||
id: "3",
|
||||
user: _mockUsers[0],
|
||||
type: FeedItemType.photos,
|
||||
title: "Maldives",
|
||||
subtitle: "shared 3 photos",
|
||||
timeAgo: "6h ago",
|
||||
isLiked: true,
|
||||
likeCount: 8,
|
||||
photos: [
|
||||
const FeedPhoto(
|
||||
id: "5",
|
||||
url: "https://images.unsplash.com/photo-1544198365-f5d60b6d8190?w=400&h=300&fit=crop",
|
||||
description: "Woman with drink on beach",
|
||||
),
|
||||
const FeedPhoto(
|
||||
id: "6",
|
||||
url: "https://images.unsplash.com/photo-1544551763-77ef2d0cfc6c?w=400&h=300&fit=crop",
|
||||
description: "Couple enjoying tropical vacation",
|
||||
),
|
||||
const FeedPhoto(
|
||||
id: "7",
|
||||
url: "https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=400&h=300&fit=crop",
|
||||
description: "Beautiful lagoon view",
|
||||
),
|
||||
],
|
||||
),
|
||||
FeedItem(
|
||||
id: "4",
|
||||
user: _mockUsers[0],
|
||||
type: FeedItemType.video,
|
||||
title: "Maldives",
|
||||
subtitle: "shared a video",
|
||||
timeAgo: "1d ago",
|
||||
likeCount: 25,
|
||||
isVideo: true,
|
||||
photos: [
|
||||
const FeedPhoto(
|
||||
id: "8",
|
||||
url: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=600&fit=crop",
|
||||
description: "Video thumbnail of tropical scenery",
|
||||
),
|
||||
],
|
||||
),
|
||||
FeedItem(
|
||||
id: "5",
|
||||
user: _mockUsers[0],
|
||||
type: FeedItemType.album,
|
||||
title: "Pets",
|
||||
subtitle: "liked an Album",
|
||||
timeAgo: "2d ago",
|
||||
likeCount: 15,
|
||||
photos: [
|
||||
const FeedPhoto(
|
||||
id: "9",
|
||||
url: "https://images.unsplash.com/photo-1552053831-71594a27632d?w=400&h=600&fit=crop",
|
||||
description: "Happy dog in a grassy field",
|
||||
),
|
||||
],
|
||||
),
|
||||
FeedItem(
|
||||
id: "6",
|
||||
user: _mockUsers[1],
|
||||
type: FeedItemType.memory,
|
||||
title: "Mountain Adventure",
|
||||
subtitle: "shared a memory",
|
||||
timeAgo: "3d ago",
|
||||
likeCount: 32,
|
||||
photos: [
|
||||
const FeedPhoto(
|
||||
id: "10",
|
||||
url: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=600&fit=crop",
|
||||
description: "Breathtaking mountain landscape view",
|
||||
),
|
||||
],
|
||||
),
|
||||
FeedItem(
|
||||
id: "7",
|
||||
user: _mockUsers[2],
|
||||
type: FeedItemType.photos,
|
||||
title: "City Lights",
|
||||
subtitle: "shared 5 photos",
|
||||
timeAgo: "1w ago",
|
||||
isLiked: true,
|
||||
likeCount: 45,
|
||||
photos: [
|
||||
const FeedPhoto(
|
||||
id: "11",
|
||||
url: "https://images.unsplash.com/photo-1519501025264-65ba15a82390?w=400&h=600&fit=crop",
|
||||
description: "Urban cityscape at night",
|
||||
),
|
||||
const FeedPhoto(
|
||||
id: "12",
|
||||
url: "https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?w=400&h=600&fit=crop",
|
||||
description: "City lights reflecting on water",
|
||||
),
|
||||
const FeedPhoto(
|
||||
id: "13",
|
||||
url: "https://images.unsplash.com/photo-1514565131-fce0801e5785?w=400&h=600&fit=crop",
|
||||
description: "Busy street with neon lights",
|
||||
),
|
||||
const FeedPhoto(
|
||||
id: "14",
|
||||
url: "https://images.unsplash.com/photo-1519501025264-65ba15a82390?w=400&h=600&fit=crop",
|
||||
description: "Skyscraper view from below",
|
||||
),
|
||||
const FeedPhoto(
|
||||
id: "15",
|
||||
url: "https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?w=400&h=600&fit=crop",
|
||||
description: "City panorama at sunset",
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static List<NotificationItem> getMockNotifications() {
|
||||
return [
|
||||
NotificationItem(
|
||||
id: "1",
|
||||
user: _mockUsers[0],
|
||||
action: "Liked your photo",
|
||||
timeAgo: "40m ago",
|
||||
photo: const FeedPhoto(
|
||||
id: "n1",
|
||||
url: "https://images.unsplash.com/photo-1552053831-71594a27632d?w=100&h=100&fit=crop",
|
||||
),
|
||||
),
|
||||
NotificationItem(
|
||||
id: "2",
|
||||
user: _mockUsers[1],
|
||||
action: "Liked your photo",
|
||||
timeAgo: "2hrs ago",
|
||||
photo: const FeedPhoto(
|
||||
id: "n2",
|
||||
url: "https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=100&h=100&fit=crop",
|
||||
),
|
||||
),
|
||||
NotificationItem(
|
||||
id: "3",
|
||||
user: _mockUsers[2],
|
||||
action: "Liked your photo",
|
||||
timeAgo: "1 day ago",
|
||||
photo: const FeedPhoto(
|
||||
id: "n3",
|
||||
url: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=100&h=100&fit=crop",
|
||||
),
|
||||
),
|
||||
NotificationItem(
|
||||
id: "4",
|
||||
user: _mockUsers[0],
|
||||
action: "Liked your album",
|
||||
timeAgo: "14 days ago",
|
||||
photo: const FeedPhoto(
|
||||
id: "n4",
|
||||
url: "https://images.unsplash.com/photo-1519501025264-65ba15a82390?w=100&h=100&fit=crop",
|
||||
),
|
||||
),
|
||||
NotificationItem(
|
||||
id: "5",
|
||||
user: _mockUsers[3],
|
||||
action: "Liked your photo",
|
||||
timeAgo: "2 mnths ago",
|
||||
photo: const FeedPhoto(
|
||||
id: "n5",
|
||||
url: "https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?w=100&h=100&fit=crop",
|
||||
),
|
||||
),
|
||||
// Read notifications
|
||||
NotificationItem(
|
||||
id: "6",
|
||||
user: _mockUsers[0],
|
||||
action: "Liked your photo",
|
||||
timeAgo: "40m ago",
|
||||
isRead: true,
|
||||
photo: const FeedPhoto(
|
||||
id: "n6",
|
||||
url: "https://images.unsplash.com/photo-1552053831-71594a27632d?w=100&h=100&fit=crop",
|
||||
),
|
||||
),
|
||||
NotificationItem(
|
||||
id: "7",
|
||||
user: _mockUsers[1],
|
||||
action: "Liked your photo",
|
||||
timeAgo: "2hrs ago",
|
||||
isRead: true,
|
||||
photo: const FeedPhoto(
|
||||
id: "n7",
|
||||
url: "https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=100&h=100&fit=crop",
|
||||
),
|
||||
),
|
||||
NotificationItem(
|
||||
id: "8",
|
||||
user: _mockUsers[2],
|
||||
action: "Liked your photo",
|
||||
timeAgo: "1 day ago",
|
||||
isRead: true,
|
||||
photo: const FeedPhoto(
|
||||
id: "n8",
|
||||
url: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=100&h=100&fit=crop",
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,17 @@ import "package:flutter/services.dart";
|
||||
import "package:photos/utils/ffprobe_util.dart";
|
||||
|
||||
class IsolatedFfmpegService {
|
||||
static Future<Map> runFfmpeg(String command) async {
|
||||
IsolatedFfmpegService._privateConstructor();
|
||||
|
||||
static final IsolatedFfmpegService instance =
|
||||
IsolatedFfmpegService._privateConstructor();
|
||||
|
||||
Future<Map> runFfmpeg(String command) async {
|
||||
final rootIsolateToken = RootIsolateToken.instance!;
|
||||
return await Isolate.run<Map>(() => _ffmpegRun(command, rootIsolateToken));
|
||||
}
|
||||
|
||||
static Future<Map> getVideoInfo(String file) async {
|
||||
Future<Map> getVideoInfo(String file) async {
|
||||
final rootIsolateToken = RootIsolateToken.instance!;
|
||||
return await Isolate.run<Map>(() => _getVideoProps(file, rootIsolateToken));
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import "package:photos/core/event_bus.dart";
|
||||
import "package:photos/events/compute_control_event.dart";
|
||||
import "package:thermal/thermal.dart";
|
||||
|
||||
enum _ComputeRunState {
|
||||
enum ComputeRunState {
|
||||
idle,
|
||||
runningML,
|
||||
generatingStream,
|
||||
@@ -35,7 +35,7 @@ class ComputeController {
|
||||
bool interactionOverride = false;
|
||||
late Timer _userInteractionTimer;
|
||||
|
||||
_ComputeRunState _currentRunState = _ComputeRunState.idle;
|
||||
ComputeRunState _currentRunState = ComputeRunState.idle;
|
||||
bool _waitingToRunML = false;
|
||||
|
||||
bool get isDeviceHealthy => _isDeviceHealthy;
|
||||
@@ -70,28 +70,44 @@ class ComputeController {
|
||||
_logger.info('init done ');
|
||||
}
|
||||
|
||||
bool requestCompute({bool ml = false, bool stream = false}) {
|
||||
_logger.info("Requesting compute: ml: $ml, stream: $stream");
|
||||
if (!_isDeviceHealthy || !_canRunGivenUserInteraction()) {
|
||||
_logger.info("Device not healthy or user interacting, denying request.");
|
||||
bool requestCompute({
|
||||
bool ml = false,
|
||||
bool stream = false,
|
||||
bool bypassInteractionCheck = false,
|
||||
}) {
|
||||
_logger.info(
|
||||
"Requesting compute: ml: $ml, stream: $stream, bypassInteraction: $bypassInteractionCheck",
|
||||
);
|
||||
if (!_isDeviceHealthy) {
|
||||
_logger.info("Device not healthy, denying request.");
|
||||
return false;
|
||||
}
|
||||
if (ml) {
|
||||
return _requestML();
|
||||
} else if (stream) {
|
||||
return _requestStream();
|
||||
if (!bypassInteractionCheck && !_canRunGivenUserInteraction()) {
|
||||
_logger.info("User interacting, denying request.");
|
||||
return false;
|
||||
}
|
||||
_logger.severe("No compute request specified, denying request.");
|
||||
return false;
|
||||
bool result = false;
|
||||
if (ml) {
|
||||
result = _requestML();
|
||||
} else if (stream) {
|
||||
result = _requestStream();
|
||||
} else {
|
||||
_logger.severe("No compute request specified, denying request.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
ComputeRunState get computeState {
|
||||
return _currentRunState;
|
||||
}
|
||||
|
||||
bool _requestML() {
|
||||
if (_currentRunState == _ComputeRunState.idle) {
|
||||
_currentRunState = _ComputeRunState.runningML;
|
||||
if (_currentRunState == ComputeRunState.idle) {
|
||||
_currentRunState = ComputeRunState.runningML;
|
||||
_waitingToRunML = false;
|
||||
_logger.info("ML request granted");
|
||||
return true;
|
||||
} else if (_currentRunState == _ComputeRunState.runningML) {
|
||||
} else if (_currentRunState == ComputeRunState.runningML) {
|
||||
return true;
|
||||
}
|
||||
_logger.info(
|
||||
@@ -102,12 +118,9 @@ class ComputeController {
|
||||
}
|
||||
|
||||
bool _requestStream() {
|
||||
if (_currentRunState == _ComputeRunState.idle && !_waitingToRunML) {
|
||||
if (_currentRunState == ComputeRunState.idle && !_waitingToRunML) {
|
||||
_logger.info("Stream request granted");
|
||||
_currentRunState = _ComputeRunState.generatingStream;
|
||||
return true;
|
||||
} else if (_currentRunState == _ComputeRunState.generatingStream &&
|
||||
!_waitingToRunML) {
|
||||
_currentRunState = ComputeRunState.generatingStream;
|
||||
return true;
|
||||
}
|
||||
_logger.info(
|
||||
@@ -122,13 +135,13 @@ class ComputeController {
|
||||
);
|
||||
|
||||
if (ml) {
|
||||
if (_currentRunState == _ComputeRunState.runningML) {
|
||||
_currentRunState = _ComputeRunState.idle;
|
||||
if (_currentRunState == ComputeRunState.runningML) {
|
||||
_currentRunState = ComputeRunState.idle;
|
||||
}
|
||||
_waitingToRunML = false;
|
||||
} else if (stream) {
|
||||
if (_currentRunState == _ComputeRunState.generatingStream) {
|
||||
_currentRunState = _ComputeRunState.idle;
|
||||
if (_currentRunState == ComputeRunState.generatingStream) {
|
||||
_currentRunState = ComputeRunState.idle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,9 @@ class MLService {
|
||||
// Listen on ComputeController
|
||||
Bus.instance.on<ComputeControlEvent>().listen((event) {
|
||||
if (!flagService.hasGrantedMLConsent) {
|
||||
if (event.shouldRun) {
|
||||
VideoPreviewService.instance.queueFiles(duration: Duration.zero);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -193,13 +193,22 @@ class SemanticSearchService {
|
||||
return results;
|
||||
}
|
||||
|
||||
Future<Map<String, List<int>>> getMatchingFileIDsWithEmbeddings(
|
||||
Map<String, List<double>> queryToEmbedding,
|
||||
Future<Map<String, List<int>>> getMatchingFileIDs(
|
||||
Map<String, double> queryToScore,
|
||||
) async {
|
||||
final textEmbeddings = <String, List<double>>{};
|
||||
final minimumSimilarityMap = <String, double>{};
|
||||
for (final entry in queryToScore.entries) {
|
||||
final query = entry.key;
|
||||
final score = entry.value;
|
||||
final textEmbedding = await _getTextEmbedding(query);
|
||||
textEmbeddings[query] = textEmbedding;
|
||||
minimumSimilarityMap[query] = score;
|
||||
}
|
||||
|
||||
final queryResults = await _getSimilarities(
|
||||
queryToEmbedding,
|
||||
minimumSimilarityMap: queryToScore,
|
||||
textEmbeddings,
|
||||
minimumSimilarityMap: minimumSimilarityMap,
|
||||
);
|
||||
final result = <String, List<int>>{};
|
||||
for (final entry in queryResults.entries) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import "dart:async";
|
||||
import "dart:convert";
|
||||
import "dart:io";
|
||||
|
||||
import "package:computer/computer.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:logging/logging.dart";
|
||||
@@ -20,12 +21,12 @@ import "package:photos/models/search/hierarchical/magic_filter.dart";
|
||||
import "package:photos/models/search/search_types.dart";
|
||||
import "package:photos/service_locator.dart";
|
||||
import "package:photos/services/machine_learning/semantic_search/semantic_search_service.dart";
|
||||
import "package:photos/services/remote_assets_service.dart";
|
||||
import "package:photos/services/search_service.dart";
|
||||
import "package:photos/ui/viewer/search/result/magic_result_screen.dart";
|
||||
import "package:photos/utils/cache_util.dart";
|
||||
import "package:photos/utils/file_util.dart";
|
||||
import "package:photos/utils/navigation_util.dart";
|
||||
import "package:photos/utils/text_embeddings_util.dart";
|
||||
import "package:shared_preferences/shared_preferences.dart";
|
||||
|
||||
class MagicCache {
|
||||
@@ -118,13 +119,8 @@ GenericSearchResult? toGenericSearchResult(
|
||||
}
|
||||
if (!prompt.recentFirst) {
|
||||
enteFilesInMagicCache.sort((a, b) {
|
||||
final aID = a.uploadedFileID;
|
||||
final bID = b.uploadedFileID;
|
||||
if (aID == null || bID == null) return 0;
|
||||
final aPos = fileIdToPositionMap[aID];
|
||||
final bPos = fileIdToPositionMap[bID];
|
||||
if (aPos == null || bPos == null) return 0;
|
||||
return aPos.compareTo(bPos);
|
||||
return fileIdToPositionMap[a.uploadedFileID!]!
|
||||
.compareTo(fileIdToPositionMap[b.uploadedFileID!]!);
|
||||
});
|
||||
}
|
||||
final String title = getLocalizedTitle(context, prompt.title);
|
||||
@@ -172,6 +168,7 @@ GenericSearchResult? toGenericSearchResult(
|
||||
|
||||
class MagicCacheService {
|
||||
static const _lastMagicCacheUpdateTime = "last_magic_cache_update_time";
|
||||
static const _kMagicPromptsDataUrl = "https://discover.ente.io/v2.json";
|
||||
|
||||
/// Delay is for cache update to be done not during app init, during which a
|
||||
/// lot of other things are happening.
|
||||
@@ -181,6 +178,7 @@ class MagicCacheService {
|
||||
late final Logger _logger = Logger((MagicCacheService).toString());
|
||||
|
||||
Future<List<MagicCache>>? _magicCacheFuture;
|
||||
Future<List<Prompt>>? _promptFuture;
|
||||
final Set<String> _pendingUpdateReason = {};
|
||||
bool _isUpdateInProgress = false;
|
||||
|
||||
@@ -215,7 +213,11 @@ class MagicCacheService {
|
||||
if (!enableDiscover) {
|
||||
return;
|
||||
}
|
||||
if (lastMagicCacheUpdateTime <
|
||||
final updatedJSONFile = await RemoteAssetsService.instance
|
||||
.getAssetIfUpdated(_kMagicPromptsDataUrl);
|
||||
if (updatedJSONFile != null) {
|
||||
queueUpdate("Prompts data updated");
|
||||
} else if (lastMagicCacheUpdateTime <
|
||||
DateTime.now()
|
||||
.subtract(const Duration(hours: 12))
|
||||
.millisecondsSinceEpoch) {
|
||||
@@ -245,7 +247,10 @@ class MagicCacheService {
|
||||
_isUpdateInProgress = true;
|
||||
final EnteWatch? w = kDebugMode ? EnteWatch("magicCacheWatch") : null;
|
||||
w?.start();
|
||||
final List<MagicCache> magicCaches = await _nonEmptyMagicResults();
|
||||
final magicPromptsData = await getPrompts();
|
||||
w?.log("loadedPrompts");
|
||||
final List<MagicCache> magicCaches =
|
||||
await _nonEmptyMagicResults(magicPromptsData);
|
||||
w?.log("resultComputed");
|
||||
_magicCacheFuture = Future.value(magicCaches);
|
||||
await writeToJsonFile<List<MagicCache>>(
|
||||
@@ -257,6 +262,7 @@ class MagicCacheService {
|
||||
await _resetLastMagicCacheUpdateTime();
|
||||
w?.logAndReset('done');
|
||||
_pendingUpdateReason.clear();
|
||||
Bus.instance.fire(MagicCacheUpdatedEvent());
|
||||
} catch (e, s) {
|
||||
_logger.info("Error updating magic cache", e, s);
|
||||
} finally {
|
||||
@@ -265,6 +271,14 @@ class MagicCacheService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Prompt>> getPrompts() async {
|
||||
if (_promptFuture != null) {
|
||||
return _promptFuture!;
|
||||
}
|
||||
_promptFuture = _readPromptFromDiskOrNetwork();
|
||||
return _promptFuture!;
|
||||
}
|
||||
|
||||
Future<List<MagicCache>> getMagicCache() async {
|
||||
if (_magicCacheFuture != null) {
|
||||
return _magicCacheFuture!;
|
||||
@@ -273,6 +287,17 @@ class MagicCacheService {
|
||||
return _magicCacheFuture!;
|
||||
}
|
||||
|
||||
Future<List<Prompt>> _readPromptFromDiskOrNetwork() async {
|
||||
final String path =
|
||||
await RemoteAssetsService.instance.getAssetPath(_kMagicPromptsDataUrl);
|
||||
return Computer.shared().compute(
|
||||
_loadMagicPrompts,
|
||||
param: <String, dynamic>{
|
||||
"path": path,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<MagicCache>> _readResultFromDisk() async {
|
||||
_logger.info("Reading magic cache result from disk");
|
||||
final cache = await decodeJsonFile<List<MagicCache>>(
|
||||
@@ -287,7 +312,6 @@ class MagicCacheService {
|
||||
if (file.existsSync()) {
|
||||
await file.delete();
|
||||
}
|
||||
_magicCacheFuture = null;
|
||||
}
|
||||
|
||||
Future<List<GenericSearchResult>> getMagicGenericSearchResult(
|
||||
@@ -298,30 +322,7 @@ class MagicCacheService {
|
||||
kDebugMode ? EnteWatch("magicGenericSearchResult") : null;
|
||||
w?.start();
|
||||
final magicCaches = await getMagicCache();
|
||||
|
||||
// Load discover embeddings to get prompts
|
||||
final discoverEmbeddings = await loadDiscoverEmbeddings();
|
||||
if (discoverEmbeddings == null) {
|
||||
_logger.severe("No discover embeddings available in assets");
|
||||
throw Exception("No discover embeddings available in assets");
|
||||
}
|
||||
|
||||
final prompts = <Prompt>[];
|
||||
for (final entry in discoverEmbeddings.queryToPromptData.entries) {
|
||||
final query = entry.key;
|
||||
final promptData = entry.value;
|
||||
prompts.add(
|
||||
Prompt(
|
||||
query: query,
|
||||
title: promptData.title,
|
||||
minScore: promptData.minScore,
|
||||
minSize: promptData.minSize,
|
||||
showVideo: promptData.showVideo ?? true,
|
||||
recentFirst: promptData.recentFirst ?? false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final List<Prompt> prompts = await getPrompts();
|
||||
if (magicCaches.isEmpty) {
|
||||
w?.log("No magic cache found");
|
||||
return [];
|
||||
@@ -374,53 +375,35 @@ class MagicCacheService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<MagicCache>> _nonEmptyMagicResults() async {
|
||||
static Future<List<Prompt>> _loadMagicPrompts(
|
||||
Map<String, dynamic> args,
|
||||
) async {
|
||||
final String path = args["path"] as String;
|
||||
final File file = File(path);
|
||||
final String contents = await file.readAsString();
|
||||
final Map<String, dynamic> promptsJson = jsonDecode(contents);
|
||||
final List<dynamic> promptData = promptsJson['prompts'];
|
||||
return promptData
|
||||
.map<Prompt>((jsonItem) => Prompt.fromJson(jsonItem))
|
||||
.toList();
|
||||
}
|
||||
|
||||
///Returns non-empty magic results from magicPromptsData
|
||||
///Length is number of prompts, can be less if there are not enough non-empty
|
||||
///results
|
||||
Future<List<MagicCache>> _nonEmptyMagicResults(
|
||||
List<Prompt> magicPromptsData,
|
||||
) async {
|
||||
final TimeLogger t = TimeLogger();
|
||||
final results = <MagicCache>[];
|
||||
final List<int> matchCount = [];
|
||||
|
||||
// Uncomment this code to generate embeddings and save them to a JSON file
|
||||
// await generateAndSaveDiscoverEmbeddings();
|
||||
// (from package:photos/utils/text_embeddings_util.dart)
|
||||
|
||||
// Load pre-computed discover embeddings from assets
|
||||
final discoverEmbeddings = await loadDiscoverEmbeddings();
|
||||
if (discoverEmbeddings == null) {
|
||||
_logger.severe('Failed to load discover embeddings');
|
||||
throw Exception('Failed to load discover embeddings');
|
||||
}
|
||||
|
||||
// Build the embeddings and score maps
|
||||
final Map<String, List<double>> queryToEmbedding = {};
|
||||
final Map<String, double> queryToScore = {};
|
||||
final List<Prompt> prompts = [];
|
||||
|
||||
for (final entry in discoverEmbeddings.queryToPromptData.entries) {
|
||||
final query = entry.key;
|
||||
final promptData = entry.value;
|
||||
final vector = discoverEmbeddings.queryToVector[query];
|
||||
|
||||
if (vector != null) {
|
||||
queryToEmbedding[query] = vector.toList();
|
||||
queryToScore[query] = promptData.minScore;
|
||||
prompts.add(
|
||||
Prompt(
|
||||
query: query,
|
||||
title: promptData.title,
|
||||
minScore: promptData.minScore,
|
||||
minSize: promptData.minSize,
|
||||
showVideo: promptData.showVideo ?? true,
|
||||
recentFirst: promptData.recentFirst ?? false,
|
||||
),
|
||||
);
|
||||
}
|
||||
for (Prompt prompt in magicPromptsData) {
|
||||
queryToScore[prompt.query] = prompt.minScore;
|
||||
}
|
||||
|
||||
_logger.info('Using pre-computed discover embeddings from assets');
|
||||
final clipResults = await SemanticSearchService.instance
|
||||
.getMatchingFileIDsWithEmbeddings(queryToEmbedding, queryToScore);
|
||||
|
||||
for (final prompt in prompts) {
|
||||
final clipResults =
|
||||
await SemanticSearchService.instance.getMatchingFileIDs(queryToScore);
|
||||
for (Prompt prompt in magicPromptsData) {
|
||||
final List<int> fileUploadedIDs = clipResults[prompt.query] ?? [];
|
||||
if (fileUploadedIDs.isNotEmpty) {
|
||||
results.add(
|
||||
|
||||
@@ -104,7 +104,7 @@ class SmartMemoriesService {
|
||||
);
|
||||
|
||||
// Load pre-computed text embeddings from assets
|
||||
final textEmbeddings = await loadMemoriesEmbeddings();
|
||||
final textEmbeddings = await loadTextEmbeddingsFromAssets();
|
||||
if (textEmbeddings == null) {
|
||||
_logger.severe('Failed to load pre-computed text embeddings');
|
||||
throw Exception(
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'package:photos/events/files_updated_event.dart';
|
||||
import 'package:photos/events/force_reload_home_gallery_event.dart';
|
||||
import 'package:photos/events/local_photos_updated_event.dart';
|
||||
import 'package:photos/events/sync_status_update_event.dart';
|
||||
import "package:photos/main.dart" show isProcessBg;
|
||||
import 'package:photos/models/device_collection.dart';
|
||||
import "package:photos/models/file/extensions/file_props.dart";
|
||||
import 'package:photos/models/file/file.dart';
|
||||
@@ -31,7 +32,6 @@ import 'package:photos/services/local_file_update_service.dart';
|
||||
import "package:photos/services/notification_service.dart";
|
||||
import 'package:photos/services/sync/diff_fetcher.dart';
|
||||
import 'package:photos/services/sync/sync_service.dart';
|
||||
import "package:photos/services/video_preview_service.dart";
|
||||
import 'package:photos/utils/file_uploader.dart';
|
||||
import 'package:photos/utils/file_util.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -132,13 +132,9 @@ class RemoteSyncService {
|
||||
}
|
||||
|
||||
if (
|
||||
// Only Uploading Previews in fg to prevent heating issues
|
||||
AppLifecycleService.instance.isForeground &&
|
||||
// if ML is enabled the MLService will queue when ML is done
|
||||
!flagService.hasGrantedMLConsent) {
|
||||
fileDataService.syncFDStatus().then((_) {
|
||||
VideoPreviewService.instance.queueFiles();
|
||||
}).ignore();
|
||||
// We don't need syncFDStatus here if in background
|
||||
!isProcessBg) {
|
||||
fileDataService.syncFDStatus().ignore();
|
||||
}
|
||||
|
||||
final filesToBeUploaded = await _getFilesToBeUploaded();
|
||||
@@ -377,7 +373,7 @@ class RemoteSyncService {
|
||||
localIDsToSync.removeAll(alreadyClaimedLocalIDs);
|
||||
if (alreadyClaimedLocalIDs.isNotEmpty && !_hasCleanupStaleEntry) {
|
||||
try {
|
||||
await _db.removeQueuedLocalFiles(alreadyClaimedLocalIDs);
|
||||
await _db.removeQueuedLocalFiles(alreadyClaimedLocalIDs, ownerID);
|
||||
} catch (e, s) {
|
||||
_logger.severe("removeQueuedLocalFiles failed", e, s);
|
||||
}
|
||||
|
||||
@@ -15,28 +15,30 @@ import "package:path_provider/path_provider.dart";
|
||||
import "package:photos/core/cache/video_cache_manager.dart";
|
||||
import "package:photos/core/configuration.dart";
|
||||
import "package:photos/core/event_bus.dart";
|
||||
import "package:photos/core/network/network.dart";
|
||||
import 'package:photos/db/files_db.dart';
|
||||
import "package:photos/db/upload_locks_db.dart";
|
||||
import "package:photos/events/video_preview_state_changed_event.dart";
|
||||
import "package:photos/events/video_streaming_changed.dart";
|
||||
import 'package:photos/generated/intl/app_localizations.dart';
|
||||
import "package:photos/models/base/id.dart";
|
||||
import "package:photos/models/ffmpeg/ffprobe_props.dart";
|
||||
import "package:photos/models/file/file.dart";
|
||||
import "package:photos/models/file/file_type.dart";
|
||||
import "package:photos/models/metadata/file_magic.dart";
|
||||
import "package:photos/models/preview/playlist_data.dart";
|
||||
import "package:photos/models/preview/preview_item.dart";
|
||||
import "package:photos/models/preview/preview_item_status.dart";
|
||||
import "package:photos/service_locator.dart";
|
||||
import "package:photos/services/collections_service.dart";
|
||||
import "package:photos/services/file_magic_service.dart";
|
||||
import "package:photos/services/filedata/model/file_data.dart";
|
||||
import "package:photos/services/isolated_ffmpeg_service.dart";
|
||||
import "package:photos/services/machine_learning/compute_controller.dart";
|
||||
import "package:photos/ui/notification/toast.dart";
|
||||
import "package:photos/utils/exif_util.dart";
|
||||
import "package:photos/utils/file_key.dart";
|
||||
import "package:photos/utils/file_util.dart";
|
||||
import "package:photos/utils/gzip.dart";
|
||||
import "package:photos/utils/network_util.dart";
|
||||
import "package:shared_preferences/shared_preferences.dart";
|
||||
|
||||
const _maxRetryCount = 3;
|
||||
|
||||
@@ -47,39 +49,54 @@ class VideoPreviewService {
|
||||
final int _maxPreviewSizeLimitForCache = 50 * 1024 * 1024; // 50 MB
|
||||
Set<int>? _failureFiles;
|
||||
|
||||
bool _hasQueuedFile = false;
|
||||
bool get _hasQueuedFile => fileQueue.isNotEmpty;
|
||||
|
||||
VideoPreviewService._privateConstructor();
|
||||
VideoPreviewService._privateConstructor()
|
||||
: serviceLocator = ServiceLocator.instance,
|
||||
filesDB = FilesDB.instance,
|
||||
uploadLocksDB = UploadLocksDB.instance,
|
||||
ffmpegService = IsolatedFfmpegService.instance,
|
||||
fileMagicService = FileMagicService.instance,
|
||||
cacheManager = DefaultCacheManager(),
|
||||
videoCacheManager = VideoCacheManager.instance,
|
||||
config = Configuration.instance;
|
||||
|
||||
VideoPreviewService(
|
||||
this.config,
|
||||
this.serviceLocator,
|
||||
this.filesDB,
|
||||
this.uploadLocksDB,
|
||||
this.fileMagicService,
|
||||
this.ffmpegService,
|
||||
this.cacheManager,
|
||||
this.videoCacheManager,
|
||||
);
|
||||
|
||||
static final VideoPreviewService instance =
|
||||
VideoPreviewService._privateConstructor();
|
||||
|
||||
final cacheManager = DefaultCacheManager();
|
||||
final videoCacheManager = VideoCacheManager.instance;
|
||||
|
||||
int uploadingFileId = -1;
|
||||
|
||||
final _enteDio = NetworkClient.instance.enteDio;
|
||||
final _nonEnteDio = NetworkClient.instance.getDio();
|
||||
final CollectionsService collectionsService = CollectionsService.instance;
|
||||
final Configuration config;
|
||||
final ServiceLocator serviceLocator;
|
||||
final FilesDB filesDB;
|
||||
final UploadLocksDB uploadLocksDB;
|
||||
final FileMagicService fileMagicService;
|
||||
final IsolatedFfmpegService ffmpegService;
|
||||
final DefaultCacheManager cacheManager;
|
||||
final CacheManager videoCacheManager;
|
||||
|
||||
void init(SharedPreferences prefs) {
|
||||
_prefs = prefs;
|
||||
}
|
||||
|
||||
late final SharedPreferences _prefs;
|
||||
static const String _videoStreamingEnabled = "videoStreamingEnabled";
|
||||
|
||||
bool get isVideoStreamingEnabled {
|
||||
return _prefs.getBool(_videoStreamingEnabled) ?? false;
|
||||
return serviceLocator.prefs.getBool(_videoStreamingEnabled) ?? false;
|
||||
}
|
||||
|
||||
Future<void> setIsVideoStreamingEnabled(bool value) async {
|
||||
_prefs.setBool(_videoStreamingEnabled, value).ignore();
|
||||
serviceLocator.prefs.setBool(_videoStreamingEnabled, value).ignore();
|
||||
Bus.instance.fire(VideoStreamingChanged());
|
||||
|
||||
if (isVideoStreamingEnabled) {
|
||||
await fileDataService.syncFDStatus();
|
||||
queueFiles(duration: Duration.zero);
|
||||
} else {
|
||||
clearQueue();
|
||||
@@ -87,13 +104,82 @@ class VideoPreviewService {
|
||||
}
|
||||
|
||||
void clearQueue() {
|
||||
// Fire events for all items being cleared
|
||||
for (final entry in _items.entries) {
|
||||
_fireVideoPreviewStateChange(entry.key, PreviewItemStatus.uploaded);
|
||||
}
|
||||
fileQueue.clear();
|
||||
_items.clear();
|
||||
_hasQueuedFile = false;
|
||||
}
|
||||
|
||||
void _fireVideoPreviewStateChange(int fileId, PreviewItemStatus status) {
|
||||
Bus.instance.fire(VideoPreviewStateChangedEvent(fileId, status));
|
||||
}
|
||||
|
||||
// Return value indicates file was successfully added to queue or not
|
||||
Future<bool> addToManualQueue(EnteFile file, String queueType) async {
|
||||
if (file.uploadedFileID == null) return false;
|
||||
|
||||
// Check if already in queue
|
||||
final bool alreadyInQueue = await uploadLocksDB.isInStreamQueue(
|
||||
file.uploadedFileID!,
|
||||
);
|
||||
if (alreadyInQueue) {
|
||||
return false; // Indicates file was already in queue
|
||||
}
|
||||
|
||||
// Add to persistent database queue
|
||||
await uploadLocksDB.addToStreamQueue(file.uploadedFileID!, queueType);
|
||||
|
||||
// Start processing if not already processing
|
||||
if (uploadingFileId < 0) {
|
||||
queueFiles(duration: Duration.zero, isManual: true);
|
||||
} else {
|
||||
_items[file.uploadedFileID!] = PreviewItem(
|
||||
status: PreviewItemStatus.inQueue,
|
||||
file: file,
|
||||
retryCount: 0,
|
||||
collectionID: file.collectionID ?? 0,
|
||||
);
|
||||
_fireVideoPreviewStateChange(
|
||||
file.uploadedFileID!,
|
||||
PreviewItemStatus.inQueue,
|
||||
);
|
||||
fileQueue[file.uploadedFileID!] = file;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool isCurrentlyProcessing(int? uploadedFileID) {
|
||||
if (uploadedFileID == null) return false;
|
||||
return uploadingFileId == uploadedFileID;
|
||||
}
|
||||
|
||||
Future<bool> _isRecreateOperation(EnteFile file) async {
|
||||
if (file.uploadedFileID == null) return false;
|
||||
|
||||
try {
|
||||
// Check database directly instead of relying on in-memory _manualQueueFiles
|
||||
// which might not be populated yet
|
||||
final manualQueueFiles = await uploadLocksDB.getStreamQueue();
|
||||
final queueType = manualQueueFiles[file.uploadedFileID!];
|
||||
return queueType == 'recreate';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ensurePreviewIdsInitialized() async {
|
||||
// Ensure fileDataService previewIds is initialized before using it
|
||||
if (fileDataService.previewIds.isEmpty) {
|
||||
await fileDataService.syncFDStatus();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> isSharedFileStreamble(EnteFile file) async {
|
||||
try {
|
||||
await _ensurePreviewIdsInitialized();
|
||||
if (fileDataService.previewIds.containsKey(file.uploadedFileID)) {
|
||||
return true;
|
||||
}
|
||||
@@ -104,14 +190,76 @@ class VideoPreviewService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<EnteFile>> _getFiles({
|
||||
DateTime? beginDate,
|
||||
bool onlyFilesWithLocalId = true,
|
||||
}) async {
|
||||
return await filesDB.getStreamingEligibleVideoFiles(
|
||||
beginDate: beginDate,
|
||||
userID: config.getUserID()!,
|
||||
onlyFilesWithLocalId: onlyFilesWithLocalId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<double> calcStatus(
|
||||
List<EnteFile> files,
|
||||
Map<int, PreviewInfo> previewIds,
|
||||
) async {
|
||||
// This is the total video files that have streams
|
||||
final Set<int> processed = previewIds.keys.toSet();
|
||||
// Total: Total Remote video files owned - skipped video files
|
||||
// + processed videos (any platform)
|
||||
final Set<int> total = {...processed};
|
||||
|
||||
for (final file in files) {
|
||||
// skipped -> don't add
|
||||
if (file.pubMagicMetadata?.sv == 1) {
|
||||
continue;
|
||||
}
|
||||
// Include the file to total set
|
||||
total.add(file.uploadedFileID!);
|
||||
}
|
||||
|
||||
// If total is empty then mark all as processed else compute the ratio
|
||||
// of processed files and total remote video files
|
||||
// netProcessedItems = processed / total
|
||||
final double netProcessedItems =
|
||||
total.isEmpty ? 1 : (processed.length / total.length).clamp(0, 1);
|
||||
|
||||
// Store the data and return it
|
||||
final status = netProcessedItems;
|
||||
return status;
|
||||
}
|
||||
|
||||
Future<double> getStatus() async {
|
||||
try {
|
||||
await _ensurePreviewIdsInitialized();
|
||||
|
||||
// This will get us all the video files that are present on remote
|
||||
// and also that could be / have been skipped due to device
|
||||
// limitations
|
||||
final files = await _getFiles(
|
||||
beginDate: null,
|
||||
onlyFilesWithLocalId: false,
|
||||
);
|
||||
|
||||
return calcStatus(files, fileDataService.previewIds);
|
||||
} catch (e, s) {
|
||||
_logger.severe('Error getting Streaming status', e, s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> chunkAndUploadVideo(
|
||||
BuildContext? ctx,
|
||||
EnteFile enteFile, [
|
||||
bool forceUpload = false,
|
||||
bool isManual = false,
|
||||
]) async {
|
||||
if (!_allowStream()) {
|
||||
final canStream = _isPermissionGranted();
|
||||
if (!canStream) {
|
||||
_logger.info(
|
||||
"Pause preview due to disabledSteaming($isVideoStreamingEnabled) or computeController permission)",
|
||||
"Pause preview due to disabledSteaming($isVideoStreamingEnabled) or computeController permission) - isManual: $isManual",
|
||||
);
|
||||
if (isVideoStreamingEnabled) _logger.info("No permission to run compute");
|
||||
clearQueue();
|
||||
@@ -126,10 +274,14 @@ class VideoPreviewService {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// check if playlist already exist
|
||||
if (await getPlaylist(enteFile) != null) {
|
||||
// check if playlist already exist, but skip this check for 'recreate' operations
|
||||
final isRecreateOperation = await _isRecreateOperation(enteFile);
|
||||
if (!isRecreateOperation && await getPlaylist(enteFile) != null) {
|
||||
if (ctx != null && ctx.mounted) {
|
||||
showShortToast(ctx, 'Video preview already exists');
|
||||
showShortToast(
|
||||
ctx,
|
||||
AppLocalizations.of(ctx).videoPreviewAlreadyExists,
|
||||
);
|
||||
}
|
||||
removeFile = true;
|
||||
return;
|
||||
@@ -143,6 +295,9 @@ class VideoPreviewService {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_logger.info(
|
||||
"Starting video preview generation for ${enteFile.displayName}",
|
||||
);
|
||||
// elimination case for <=10 MB with H.264
|
||||
var (props, result, file) = await _checkFileForPreviewCreation(enteFile);
|
||||
if (result) {
|
||||
@@ -162,6 +317,10 @@ class VideoPreviewService {
|
||||
: _items[enteFile.uploadedFileID!]?.retryCount ?? 0,
|
||||
collectionID: enteFile.collectionID ?? 0,
|
||||
);
|
||||
_fireVideoPreviewStateChange(
|
||||
enteFile.uploadedFileID!,
|
||||
PreviewItemStatus.inQueue,
|
||||
);
|
||||
fileQueue[enteFile.uploadedFileID!] = enteFile;
|
||||
return;
|
||||
}
|
||||
@@ -175,6 +334,10 @@ class VideoPreviewService {
|
||||
forceUpload ? 0 : _items[enteFile.uploadedFileID!]?.retryCount ?? 0,
|
||||
collectionID: enteFile.collectionID ?? 0,
|
||||
);
|
||||
_fireVideoPreviewStateChange(
|
||||
enteFile.uploadedFileID!,
|
||||
PreviewItemStatus.compressing,
|
||||
);
|
||||
|
||||
// get file
|
||||
file ??= await getFile(enteFile, isOrigin: true);
|
||||
@@ -187,8 +350,9 @@ class VideoPreviewService {
|
||||
props ??= await getVideoPropsAsync(file);
|
||||
final fileSize = enteFile.fileSize ?? file.lengthSync();
|
||||
|
||||
final videoData = List.from(props?.propData?["streams"] ?? [])
|
||||
.firstWhereOrNull((e) => e["type"] == "video");
|
||||
final videoData = List.from(
|
||||
props?.propData?["streams"] ?? [],
|
||||
).firstWhereOrNull((e) => e["type"] == "video");
|
||||
|
||||
final codec = videoData["codec_name"]?.toString().toLowerCase();
|
||||
final isH264 = codec?.contains("h264") ?? false;
|
||||
@@ -203,7 +367,7 @@ class VideoPreviewService {
|
||||
(colorTransfer == "smpte2084" || colorTransfer == "arib-std-b67");
|
||||
|
||||
// create temp file & directory for preview generation
|
||||
final String tempDir = Configuration.instance.getTempDirectory();
|
||||
final String tempDir = config.getTempDirectory();
|
||||
final String prefix =
|
||||
"${tempDir}_${enteFile.uploadedFileID}_${newID("pv")}";
|
||||
Directory(prefix).createSync();
|
||||
@@ -214,8 +378,10 @@ class VideoPreviewService {
|
||||
keyfile.writeAsBytesSync(key.bytes);
|
||||
|
||||
final keyinfo = File('$prefix/mykey.keyinfo');
|
||||
keyinfo.writeAsStringSync("data:text/plain;base64,${key.base64}\n"
|
||||
"${keyfile.path}\n");
|
||||
keyinfo.writeAsStringSync(
|
||||
"data:text/plain;base64,${key.base64}\n"
|
||||
"${keyfile.path}\n",
|
||||
);
|
||||
|
||||
_logger.info(
|
||||
'Generating HLS Playlist ${enteFile.displayName} at $prefix/output.m3u8',
|
||||
@@ -267,19 +433,19 @@ class VideoPreviewService {
|
||||
|
||||
_logger.info(command);
|
||||
|
||||
final playlistGenResult = await IsolatedFfmpegService.runFfmpeg(
|
||||
final playlistGenResult = await ffmpegService
|
||||
.runFfmpeg(
|
||||
// input file path
|
||||
'-i "${file.path}" ' +
|
||||
// main params for streaming
|
||||
command +
|
||||
// output file path
|
||||
'$prefix/output.m3u8',
|
||||
).onError(
|
||||
(error, stackTrace) {
|
||||
_logger.warning("FFmpeg command failed", error, stackTrace);
|
||||
return {};
|
||||
},
|
||||
);
|
||||
)
|
||||
.onError((error, stackTrace) {
|
||||
_logger.warning("FFmpeg command failed", error, stackTrace);
|
||||
return {};
|
||||
});
|
||||
|
||||
final playlistGenReturnCode = playlistGenResult["returnCode"] as int?;
|
||||
|
||||
@@ -294,6 +460,10 @@ class VideoPreviewService {
|
||||
collectionID: enteFile.collectionID ?? 0,
|
||||
retryCount: _items[enteFile.uploadedFileID!]?.retryCount ?? 0,
|
||||
);
|
||||
_fireVideoPreviewStateChange(
|
||||
enteFile.uploadedFileID!,
|
||||
PreviewItemStatus.uploading,
|
||||
);
|
||||
|
||||
_logger.info('Playlist Generated ${enteFile.displayName}');
|
||||
|
||||
@@ -305,18 +475,18 @@ class VideoPreviewService {
|
||||
objectSize = result.$2;
|
||||
|
||||
// Fetch resolution of generated stream by decrypting a single frame
|
||||
final playlistFrameResult = await IsolatedFfmpegService.runFfmpeg(
|
||||
final playlistFrameResult = await ffmpegService
|
||||
.runFfmpeg(
|
||||
'-allowed_extensions ALL -i "$prefix/output.m3u8" -frames:v 1 -c copy "$prefix/frame.ts"',
|
||||
).onError(
|
||||
(error, stackTrace) {
|
||||
_logger.warning(
|
||||
"FFmpeg command failed for frame",
|
||||
error,
|
||||
stackTrace,
|
||||
);
|
||||
return {};
|
||||
},
|
||||
);
|
||||
)
|
||||
.onError((error, stackTrace) {
|
||||
_logger.warning(
|
||||
"FFmpeg command failed for frame",
|
||||
error,
|
||||
stackTrace,
|
||||
);
|
||||
return {};
|
||||
});
|
||||
final playlistFrameReturnCode =
|
||||
playlistFrameResult["returnCode"] as int?;
|
||||
int? width, height;
|
||||
@@ -373,11 +543,14 @@ class VideoPreviewService {
|
||||
retryCount: _items[enteFile.uploadedFileID!]!.retryCount,
|
||||
collectionID: enteFile.collectionID ?? 0,
|
||||
);
|
||||
_fireVideoPreviewStateChange(
|
||||
enteFile.uploadedFileID!,
|
||||
PreviewItemStatus.uploaded,
|
||||
);
|
||||
_removeFromLocks(enteFile).ignore();
|
||||
Directory(prefix).delete(recursive: true).ignore();
|
||||
}
|
||||
} finally {
|
||||
computeController.releaseCompute(stream: true);
|
||||
if (error != null) {
|
||||
_retryFile(enteFile, error);
|
||||
} else if (removeFile) {
|
||||
@@ -397,6 +570,8 @@ class VideoPreviewService {
|
||||
final file = entry.value;
|
||||
fileQueue.remove(entry.key);
|
||||
await chunkAndUploadVideo(ctx, file);
|
||||
} else {
|
||||
computeController.releaseCompute(stream: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -404,16 +579,28 @@ class VideoPreviewService {
|
||||
Future<void> _removeFromLocks(EnteFile enteFile) async {
|
||||
final bool isFailurePresent =
|
||||
_failureFiles?.contains(enteFile.uploadedFileID!) ?? false;
|
||||
final bool isInManualQueue = await uploadLocksDB.isInStreamQueue(
|
||||
enteFile.uploadedFileID!,
|
||||
);
|
||||
|
||||
if (isFailurePresent) {
|
||||
await UploadLocksDB.instance
|
||||
.deleteStreamUploadErrorEntry(enteFile.uploadedFileID!);
|
||||
await uploadLocksDB.deleteStreamUploadErrorEntry(
|
||||
enteFile.uploadedFileID!,
|
||||
);
|
||||
_failureFiles?.remove(enteFile.uploadedFileID!);
|
||||
}
|
||||
|
||||
if (isInManualQueue) {
|
||||
await uploadLocksDB.removeFromStreamQueue(enteFile.uploadedFileID!);
|
||||
}
|
||||
}
|
||||
|
||||
void _removeFile(EnteFile enteFile) {
|
||||
_items.remove(enteFile.uploadedFileID!);
|
||||
final fileId = enteFile.uploadedFileID!;
|
||||
_items.remove(fileId);
|
||||
// Note: Using 'uploaded' status as there's no 'removed' status in PreviewItemStatus
|
||||
// This indicates the item has been successfully processed and removed from queue
|
||||
_fireVideoPreviewStateChange(fileId, PreviewItemStatus.uploaded);
|
||||
}
|
||||
|
||||
void _retryFile(EnteFile enteFile, Object error) {
|
||||
@@ -424,6 +611,10 @@ class VideoPreviewService {
|
||||
retryCount: _items[enteFile.uploadedFileID!]!.retryCount + 1,
|
||||
collectionID: enteFile.collectionID ?? 0,
|
||||
);
|
||||
_fireVideoPreviewStateChange(
|
||||
enteFile.uploadedFileID!,
|
||||
PreviewItemStatus.retry,
|
||||
);
|
||||
fileQueue[enteFile.uploadedFileID!] = enteFile;
|
||||
} else {
|
||||
_items[enteFile.uploadedFileID!] = PreviewItem(
|
||||
@@ -433,17 +624,21 @@ class VideoPreviewService {
|
||||
collectionID: enteFile.collectionID ?? 0,
|
||||
error: error,
|
||||
);
|
||||
_fireVideoPreviewStateChange(
|
||||
enteFile.uploadedFileID!,
|
||||
PreviewItemStatus.failed,
|
||||
);
|
||||
|
||||
final bool isFailurePresent =
|
||||
_failureFiles?.contains(enteFile.uploadedFileID!) ?? false;
|
||||
|
||||
if (isFailurePresent) {
|
||||
UploadLocksDB.instance.appendStreamEntry(
|
||||
uploadLocksDB.appendStreamEntry(
|
||||
enteFile.uploadedFileID!,
|
||||
error.toString(),
|
||||
);
|
||||
} else {
|
||||
UploadLocksDB.instance.appendStreamEntry(
|
||||
uploadLocksDB.appendStreamEntry(
|
||||
enteFile.uploadedFileID!,
|
||||
error.toString(),
|
||||
);
|
||||
@@ -474,7 +669,7 @@ class VideoPreviewService {
|
||||
},
|
||||
encryptionKey,
|
||||
);
|
||||
final _ = await _enteDio.put(
|
||||
final _ = await serviceLocator.enteDio.put(
|
||||
"/files/video-data",
|
||||
data: {
|
||||
"fileID": file.uploadedFileID!,
|
||||
@@ -493,7 +688,7 @@ class VideoPreviewService {
|
||||
Future<(String, int)> _uploadPreviewVideo(EnteFile file, File preview) async {
|
||||
_logger.info("Pushing preview for $file");
|
||||
try {
|
||||
final response = await _enteDio.get(
|
||||
final response = await serviceLocator.enteDio.get(
|
||||
"/files/data/preview-upload-url",
|
||||
queryParameters: {
|
||||
"fileID": file.uploadedFileID!,
|
||||
@@ -503,14 +698,10 @@ class VideoPreviewService {
|
||||
final uploadURL = response.data["url"];
|
||||
final String objectID = response.data["objectID"];
|
||||
final objectSize = preview.lengthSync();
|
||||
final _ = await _enteDio.put(
|
||||
final _ = await serviceLocator.enteDio.put(
|
||||
uploadURL,
|
||||
data: preview.openRead(),
|
||||
options: Options(
|
||||
headers: {
|
||||
Headers.contentLengthHeader: objectSize,
|
||||
},
|
||||
),
|
||||
options: Options(headers: {Headers.contentLengthHeader: objectSize}),
|
||||
);
|
||||
return (objectID, objectSize);
|
||||
} catch (e) {
|
||||
@@ -538,6 +729,7 @@ class VideoPreviewService {
|
||||
Future<PlaylistData?> _getPlaylist(EnteFile file) async {
|
||||
_logger.info("Getting playlist for $file");
|
||||
int? width, height, size;
|
||||
|
||||
try {
|
||||
late final String objectID;
|
||||
final PreviewInfo? previewInfo =
|
||||
@@ -553,8 +745,9 @@ class VideoPreviewService {
|
||||
objectID = previewInfo.objectId;
|
||||
}
|
||||
|
||||
final FileInfo? playlistCache =
|
||||
await cacheManager.getFileFromCache(_getCacheKey(objectID));
|
||||
final FileInfo? playlistCache = await cacheManager.getFileFromCache(
|
||||
_getCacheKey(objectID),
|
||||
);
|
||||
final detailsCache = await cacheManager.getFileFromCache(
|
||||
_getDetailsCacheKey(objectID),
|
||||
);
|
||||
@@ -576,9 +769,7 @@ class VideoPreviewService {
|
||||
unawaited(
|
||||
cacheManager.putFile(
|
||||
_getCacheKey(objectID),
|
||||
Uint8List.fromList(
|
||||
(playlistData["playlist"] as String).codeUnits,
|
||||
),
|
||||
Uint8List.fromList((playlistData["playlist"] as String).codeUnits),
|
||||
),
|
||||
);
|
||||
unawaited(
|
||||
@@ -594,8 +785,9 @@ class VideoPreviewService {
|
||||
),
|
||||
);
|
||||
}
|
||||
final videoFile = (await videoCacheManager
|
||||
.getFileFromCache(_getVideoPreviewKey(objectID)))
|
||||
final videoFile = (await videoCacheManager.getFileFromCache(
|
||||
_getVideoPreviewKey(objectID),
|
||||
))
|
||||
?.file;
|
||||
if (videoFile == null) {
|
||||
previewURLResult = previewURLResult ?? await _getPreviewUrl(file);
|
||||
@@ -607,21 +799,26 @@ class VideoPreviewService {
|
||||
),
|
||||
);
|
||||
}
|
||||
finalPlaylist =
|
||||
finalPlaylist.replaceAll('\noutput.ts', '\n${previewURLResult.$1}');
|
||||
finalPlaylist = finalPlaylist.replaceAll(
|
||||
'\noutput.ts',
|
||||
'\n${previewURLResult.$1}',
|
||||
);
|
||||
} else {
|
||||
finalPlaylist =
|
||||
finalPlaylist.replaceAll('\noutput.ts', '\n${videoFile.path}');
|
||||
finalPlaylist = finalPlaylist.replaceAll(
|
||||
'\noutput.ts',
|
||||
'\n${videoFile.path}',
|
||||
);
|
||||
}
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final playlistFile = File("${tempDir.path}/${file.uploadedFileID}.m3u8");
|
||||
await playlistFile.writeAsString(finalPlaylist);
|
||||
final String log = (StringBuffer()
|
||||
..write("[CACHE-STATUS] ")
|
||||
..write("Video: ${videoFile != null ? '✓' : '✗'} | ")
|
||||
..write("Details: ${detailsCache != null ? '✓' : '✗'} | ")
|
||||
..write("Playlist: ${playlistCache != null ? '✓' : '✗'}"))
|
||||
.toString();
|
||||
final String log = (
|
||||
StringBuffer()
|
||||
..write("[CACHE-STATUS] ")
|
||||
..write("Video: ${videoFile != null ? '✓' : '✗'} | ")
|
||||
..write("Details: ${detailsCache != null ? '✓' : '✗'} | ")
|
||||
..write("Playlist: ${playlistCache != null ? '✓' : '✗'}"),
|
||||
).toString();
|
||||
_logger.info("Mapped playlist to ${playlistFile.path}, $log");
|
||||
final data = PlaylistData(
|
||||
preview: playlistFile,
|
||||
@@ -631,11 +828,7 @@ class VideoPreviewService {
|
||||
durationInSeconds: parseDurationFromHLS(finalPlaylist),
|
||||
);
|
||||
if (shouldAppendPreview) {
|
||||
fileDataService.appendPreview(
|
||||
file.uploadedFileID!,
|
||||
objectID,
|
||||
size!,
|
||||
);
|
||||
fileDataService.appendPreview(file.uploadedFileID!, objectID, size!);
|
||||
}
|
||||
return data;
|
||||
} catch (_) {
|
||||
@@ -646,24 +839,19 @@ class VideoPreviewService {
|
||||
Future<Map<String, dynamic>> _getPlaylistData(EnteFile file) async {
|
||||
late Response<dynamic> response;
|
||||
if (collectionsService.isSharedPublicLink(file.collectionID!)) {
|
||||
response = await _nonEnteDio.get(
|
||||
"${Configuration.instance.getHttpEndpoint()}/public-collection/files/data/fetch/",
|
||||
queryParameters: {
|
||||
"fileID": file.uploadedFileID,
|
||||
"type": "vid_preview",
|
||||
},
|
||||
response = await serviceLocator.nonEnteDio.get(
|
||||
"${config.getHttpEndpoint()}/public-collection/files/data/fetch/",
|
||||
queryParameters: {"fileID": file.uploadedFileID, "type": "vid_preview"},
|
||||
options: Options(
|
||||
headers:
|
||||
collectionsService.publicCollectionHeaders(file.collectionID!),
|
||||
headers: collectionsService.publicCollectionHeaders(
|
||||
file.collectionID!,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
response = await _enteDio.get(
|
||||
response = await serviceLocator.enteDio.get(
|
||||
"/files/data/fetch/",
|
||||
queryParameters: {
|
||||
"fileID": file.uploadedFileID,
|
||||
"type": "vid_preview",
|
||||
},
|
||||
queryParameters: {"fileID": file.uploadedFileID, "type": "vid_preview"},
|
||||
);
|
||||
}
|
||||
final encryptedData = response.data["data"]["encryptedData"];
|
||||
@@ -683,10 +871,7 @@ class VideoPreviewService {
|
||||
for (final line in lines) {
|
||||
if (line.startsWith("#EXTINF:")) {
|
||||
// Extract duration value (e.g., "#EXTINF:2.400000," → "2.400000")
|
||||
final durationStr = line.substring(
|
||||
8,
|
||||
line.length - 1,
|
||||
);
|
||||
final durationStr = line.substring(8, line.length - 1);
|
||||
final duration = double.tryParse(durationStr);
|
||||
if (duration != null) {
|
||||
totalDuration += duration;
|
||||
@@ -700,21 +885,22 @@ class VideoPreviewService {
|
||||
try {
|
||||
late String url;
|
||||
if (collectionsService.isSharedPublicLink(file.collectionID!)) {
|
||||
final response = await _nonEnteDio.get(
|
||||
"${Configuration.instance.getHttpEndpoint()}/public-collection/files/data/preview",
|
||||
final response = await serviceLocator.nonEnteDio.get(
|
||||
"${config.getHttpEndpoint()}/public-collection/files/data/preview",
|
||||
queryParameters: {
|
||||
"fileID": file.uploadedFileID,
|
||||
"type":
|
||||
file.fileType == FileType.video ? "vid_preview" : "img_preview",
|
||||
},
|
||||
options: Options(
|
||||
headers:
|
||||
collectionsService.publicCollectionHeaders(file.collectionID!),
|
||||
headers: collectionsService.publicCollectionHeaders(
|
||||
file.collectionID!,
|
||||
),
|
||||
),
|
||||
);
|
||||
url = (response.data["url"] as String);
|
||||
} else {
|
||||
final response = await _enteDio.get(
|
||||
final response = await serviceLocator.enteDio.get(
|
||||
"/files/data/preview",
|
||||
queryParameters: {
|
||||
"fileID": file.uploadedFileID,
|
||||
@@ -739,9 +925,7 @@ class VideoPreviewService {
|
||||
EnteFile enteFile,
|
||||
) async {
|
||||
if ((enteFile.pubMagicMetadata?.sv ?? 0) == 1) {
|
||||
_logger.info(
|
||||
"Skip Preview due to sv=1 for ${enteFile.displayName}",
|
||||
);
|
||||
_logger.info("Skip Preview due to sv=1 for ${enteFile.displayName}");
|
||||
return (null, true, null);
|
||||
}
|
||||
if (enteFile.fileSize == null || enteFile.duration == null) {
|
||||
@@ -753,9 +937,7 @@ class VideoPreviewService {
|
||||
final int size = enteFile.fileSize!;
|
||||
final int duration = enteFile.duration!;
|
||||
if (size >= 500 * 1024 * 1024 || duration > 60) {
|
||||
_logger.info(
|
||||
"Skip Preview due to size: $size or duration: $duration",
|
||||
);
|
||||
_logger.info("Skip Preview due to size: $size or duration: $duration");
|
||||
return (null, true, null);
|
||||
}
|
||||
FFProbeProps? props;
|
||||
@@ -767,8 +949,9 @@ class VideoPreviewService {
|
||||
file = await getFile(enteFile, isOrigin: true);
|
||||
if (file != null) {
|
||||
props = await getVideoPropsAsync(file);
|
||||
final videoData = List.from(props?.propData?["streams"] ?? [])
|
||||
.firstWhereOrNull((e) => e["type"] == "video");
|
||||
final videoData = List.from(
|
||||
props?.propData?["streams"] ?? [],
|
||||
).firstWhereOrNull((e) => e["type"] == "video");
|
||||
final codec = videoData["codec_name"]?.toString().toLowerCase();
|
||||
skipFile = codec?.contains("h264") ?? false;
|
||||
|
||||
@@ -776,6 +959,10 @@ class VideoPreviewService {
|
||||
_logger.info(
|
||||
"[init] Ignoring file ${enteFile.displayName} for preview due to codec",
|
||||
);
|
||||
await fileMagicService.updatePublicMagicMetadata(
|
||||
[enteFile],
|
||||
{streamVersionKey: 1},
|
||||
);
|
||||
return (props, skipFile, file);
|
||||
}
|
||||
}
|
||||
@@ -787,35 +974,97 @@ class VideoPreviewService {
|
||||
}
|
||||
|
||||
// generate stream for all files after cutoff date
|
||||
Future<void> _putFilesForPreviewCreation([bool updateInit = false]) async {
|
||||
Future<void> _putFilesForPreviewCreation() async {
|
||||
if (!isVideoStreamingEnabled || !await canUseHighBandwidth()) return;
|
||||
|
||||
if (updateInit) _hasQueuedFile = true;
|
||||
|
||||
Map<int, String> failureFiles = {};
|
||||
Map<int, String> manualQueueFiles = {};
|
||||
try {
|
||||
failureFiles = await UploadLocksDB.instance.getStreamUploadError();
|
||||
failureFiles = await uploadLocksDB.getStreamUploadError();
|
||||
_failureFiles = {...failureFiles.keys};
|
||||
|
||||
manualQueueFiles = await uploadLocksDB.getStreamQueue();
|
||||
|
||||
// handle case when failures are already previewed
|
||||
for (final failure in _failureFiles!) {
|
||||
if (_items.containsKey(failure)) {
|
||||
UploadLocksDB.instance.deleteStreamUploadErrorEntry(failure).ignore();
|
||||
uploadLocksDB.deleteStreamUploadErrorEntry(failure).ignore();
|
||||
}
|
||||
}
|
||||
|
||||
// handle case when manual queue items are already previewed (for 'create' type only)
|
||||
for (final queueItem in manualQueueFiles.keys) {
|
||||
final queueType = manualQueueFiles[queueItem];
|
||||
final hasPreview = fileDataService.previewIds[queueItem] != null;
|
||||
if (hasPreview && queueType == 'create') {
|
||||
// Remove from queue only if it's a 'create' type and preview exists
|
||||
await uploadLocksDB.removeFromStreamQueue(queueItem);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh manual queue after cleanup
|
||||
manualQueueFiles = await uploadLocksDB.getStreamQueue();
|
||||
} catch (_) {}
|
||||
|
||||
final files = await FilesDB.instance.getAllFilesAfterDate(
|
||||
fileType: FileType.video,
|
||||
beginDate: DateTime.now().subtract(
|
||||
const Duration(days: 30),
|
||||
),
|
||||
userID: Configuration.instance.getUserID()!,
|
||||
final files = await _getFiles(
|
||||
beginDate: DateTime.now().subtract(const Duration(days: 60)),
|
||||
onlyFilesWithLocalId: true,
|
||||
);
|
||||
final previewIds = fileDataService.previewIds;
|
||||
|
||||
_logger.info(
|
||||
"[init] Found ${files.length} files in last 60 days, ${manualQueueFiles.length} manual queue files: ${manualQueueFiles.keys.toList()}",
|
||||
);
|
||||
|
||||
final previewIds = fileDataService.previewIds;
|
||||
final allFiles =
|
||||
files.where((file) => previewIds[file.uploadedFileID] == null).toList();
|
||||
// Add manual queue files first (they have priority)
|
||||
for (final queueFileId in manualQueueFiles.keys) {
|
||||
final queueType = manualQueueFiles[queueFileId] ?? 'create';
|
||||
final hasPreview = previewIds[queueFileId] != null;
|
||||
|
||||
// For create, only add if no preview exists
|
||||
if (queueType == 'create' && hasPreview) {
|
||||
_logger.info(
|
||||
"[manual-queue] Skipping file $queueFileId (type=$queueType, hasPreview=$hasPreview)",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// First try to find the file in the 60-day list
|
||||
var queueFile = files.firstWhereOrNull(
|
||||
(f) => f.uploadedFileID == queueFileId,
|
||||
);
|
||||
|
||||
// If not found in 60-day list, fetch it individually
|
||||
queueFile ??=
|
||||
await filesDB.getAnyUploadedFile(queueFileId).catchError((e) => null);
|
||||
|
||||
if (queueFile == null) {
|
||||
await uploadLocksDB
|
||||
.removeFromStreamQueue(queueFileId)
|
||||
.catchError((e) {});
|
||||
continue;
|
||||
}
|
||||
|
||||
_items[queueFile.uploadedFileID!] = PreviewItem(
|
||||
status: PreviewItemStatus.inQueue,
|
||||
file: queueFile,
|
||||
collectionID: queueFile.collectionID ?? 0,
|
||||
);
|
||||
_fireVideoPreviewStateChange(
|
||||
queueFile.uploadedFileID!,
|
||||
PreviewItemStatus.inQueue,
|
||||
);
|
||||
fileQueue[queueFile.uploadedFileID!] = queueFile;
|
||||
}
|
||||
|
||||
// Then add regular files that need processing
|
||||
final allFiles = files
|
||||
.where(
|
||||
(file) =>
|
||||
previewIds[file.uploadedFileID] == null &&
|
||||
!manualQueueFiles.containsKey(file.uploadedFileID),
|
||||
)
|
||||
.toList();
|
||||
|
||||
// set all video status to in queue
|
||||
var n = allFiles.length, i = 0;
|
||||
@@ -831,6 +1080,10 @@ class VideoPreviewService {
|
||||
retryCount: _maxRetryCount,
|
||||
error: failureFiles[enteFile.uploadedFileID!],
|
||||
);
|
||||
_fireVideoPreviewStateChange(
|
||||
enteFile.uploadedFileID!,
|
||||
PreviewItemStatus.failed,
|
||||
);
|
||||
}
|
||||
if (isFailure) {
|
||||
_logger.info(
|
||||
@@ -846,25 +1099,29 @@ class VideoPreviewService {
|
||||
file: enteFile,
|
||||
collectionID: enteFile.collectionID ?? 0,
|
||||
);
|
||||
_fireVideoPreviewStateChange(
|
||||
enteFile.uploadedFileID!,
|
||||
PreviewItemStatus.inQueue,
|
||||
);
|
||||
fileQueue[enteFile.uploadedFileID!] = enteFile;
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
if (allFiles.isEmpty) {
|
||||
final totalFiles = fileQueue.length;
|
||||
if (totalFiles == 0) {
|
||||
_logger.info("[init] No preview to cache");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.info("[init] Processing ${allFiles.length} items for streaming");
|
||||
_logger.info(
|
||||
"[init] Processing $totalFiles items for streaming (${manualQueueFiles.length} manual requested, ${fileQueue.length} queued, ${allFiles.length} regular)",
|
||||
);
|
||||
|
||||
// take first file and put it for stream generation
|
||||
final file = allFiles.removeAt(0);
|
||||
for (final enteFile in allFiles) {
|
||||
if (_items.containsKey(enteFile.uploadedFileID!)) {
|
||||
continue;
|
||||
}
|
||||
fileQueue[enteFile.uploadedFileID!] = enteFile;
|
||||
}
|
||||
final entry = fileQueue.entries.first;
|
||||
final file = entry.value;
|
||||
fileQueue.remove(entry.key);
|
||||
chunkAndUploadVideo(null, file).ignore();
|
||||
}
|
||||
|
||||
@@ -873,13 +1130,31 @@ class VideoPreviewService {
|
||||
computeController.requestCompute(stream: true);
|
||||
}
|
||||
|
||||
void queueFiles({Duration duration = const Duration(seconds: 5)}) {
|
||||
Future.delayed(duration, () {
|
||||
if (!_hasQueuedFile && _allowStream()) {
|
||||
_putFilesForPreviewCreation(true).catchError((_) {
|
||||
_hasQueuedFile = false;
|
||||
});
|
||||
}
|
||||
bool _allowManualStream() {
|
||||
return isVideoStreamingEnabled &&
|
||||
computeController.requestCompute(
|
||||
stream: true,
|
||||
bypassInteractionCheck: true,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isPermissionGranted() {
|
||||
return isVideoStreamingEnabled &&
|
||||
computeController.computeState == ComputeRunState.generatingStream;
|
||||
}
|
||||
|
||||
void queueFiles({
|
||||
Duration duration = const Duration(seconds: 5),
|
||||
bool isManual = false,
|
||||
}) {
|
||||
Future.delayed(duration, () async {
|
||||
if (_hasQueuedFile) return;
|
||||
|
||||
final isStreamAllowed = isManual ? _allowManualStream() : _allowStream();
|
||||
if (!isStreamAllowed) return;
|
||||
|
||||
await _ensurePreviewIdsInitialized();
|
||||
await _putFilesForPreviewCreation();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,12 +276,12 @@ class _CollectionActionSheetState extends State<CollectionActionSheet> {
|
||||
isDisabled: _selectedCollections.isEmpty,
|
||||
onTap: () async {
|
||||
if (widget.selectedPeople != null) {
|
||||
final ProgressDialog? dialog = createProgressDialog(
|
||||
final ProgressDialog dialog = createProgressDialog(
|
||||
context,
|
||||
AppLocalizations.of(context).uploadingFilesToAlbum,
|
||||
isDismissible: true,
|
||||
);
|
||||
await dialog?.show();
|
||||
await dialog.show();
|
||||
for (final collection in _selectedCollections) {
|
||||
try {
|
||||
await smartAlbumsService.addPeopleToSmartAlbum(
|
||||
@@ -297,7 +297,7 @@ class _CollectionActionSheetState extends State<CollectionActionSheet> {
|
||||
}
|
||||
}
|
||||
unawaited(smartAlbumsService.syncSmartAlbums());
|
||||
await dialog?.hide();
|
||||
await dialog.hide();
|
||||
return;
|
||||
}
|
||||
final CollectionActions collectionActions =
|
||||
|
||||
@@ -412,6 +412,7 @@ class _ButtonChildWidgetState extends State<ButtonChildWidget> {
|
||||
if (widget.onTap != null) {
|
||||
_debouncer.run(
|
||||
() => Future(() {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
executionState = ExecutionState.inProgress;
|
||||
});
|
||||
@@ -432,6 +433,7 @@ class _ButtonChildWidgetState extends State<ButtonChildWidget> {
|
||||
: null;
|
||||
_debouncer.cancelDebounceTimer();
|
||||
if (executionState == ExecutionState.successful) {
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@@ -470,6 +472,7 @@ class _ButtonChildWidgetState extends State<ButtonChildWidget> {
|
||||
}
|
||||
}
|
||||
if (executionState == ExecutionState.error) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
executionState = ExecutionState.idle;
|
||||
widget.isInAlert
|
||||
@@ -512,6 +515,7 @@ class _ButtonChildWidgetState extends State<ButtonChildWidget> {
|
||||
}
|
||||
|
||||
void _onTapDown(details) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
buttonColor = widget.buttonStyle.pressedButtonColor ??
|
||||
widget.buttonStyle.defaultButtonColor;
|
||||
@@ -527,13 +531,17 @@ class _ButtonChildWidgetState extends State<ButtonChildWidget> {
|
||||
void _onTapUp(details) {
|
||||
Future.delayed(
|
||||
const Duration(milliseconds: 84),
|
||||
() => setState(() {
|
||||
setAllStylesToDefault();
|
||||
}),
|
||||
() {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
setAllStylesToDefault();
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onTapCancel() {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
setAllStylesToDefault();
|
||||
});
|
||||
|
||||
@@ -35,6 +35,8 @@ class MenuItemWidget extends StatefulWidget {
|
||||
final Color? menuItemColor;
|
||||
final bool alignCaptionedTextToLeft;
|
||||
|
||||
final EdgeInsets? padding;
|
||||
|
||||
// singleBorderRadius is applied to the border when it's a standalone menu item.
|
||||
// Widget will apply singleBorderRadius if value of both isTopBorderRadiusRemoved
|
||||
// and isBottomBorderRadiusRemoved is false. Otherwise, multipleBorderRadius will
|
||||
@@ -88,6 +90,7 @@ class MenuItemWidget extends StatefulWidget {
|
||||
this.showOnlyLoadingState = false,
|
||||
this.surfaceExecutionStates = true,
|
||||
this.alwaysShowSuccessState = false,
|
||||
this.padding,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -97,8 +100,9 @@ class MenuItemWidget extends StatefulWidget {
|
||||
|
||||
class _MenuItemWidgetState extends State<MenuItemWidget> {
|
||||
final _debouncer = Debouncer(const Duration(milliseconds: 300));
|
||||
ValueNotifier<ExecutionState> executionStateNotifier =
|
||||
ValueNotifier(ExecutionState.idle);
|
||||
ValueNotifier<ExecutionState> executionStateNotifier = ValueNotifier(
|
||||
ExecutionState.idle,
|
||||
);
|
||||
|
||||
Color? menuItemColor;
|
||||
late double borderRadius;
|
||||
@@ -167,7 +171,7 @@ class _MenuItemWidgetState extends State<MenuItemWidget> {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 20),
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.only(left: 16, right: 12),
|
||||
padding: widget.padding ?? const EdgeInsets.only(left: 16, right: 12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: topBorderRadius,
|
||||
@@ -180,14 +184,13 @@ class _MenuItemWidgetState extends State<MenuItemWidget> {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
widget.alignCaptionedTextToLeft && widget.leadingIcon == null
|
||||
? const SizedBox.shrink()
|
||||
: LeadingWidget(
|
||||
leadingIconSize: widget.leadingIconSize,
|
||||
leadingIcon: widget.leadingIcon,
|
||||
leadingIconColor: widget.leadingIconColor,
|
||||
leadingIconWidget: widget.leadingIconWidget,
|
||||
),
|
||||
if (!widget.alignCaptionedTextToLeft || widget.leadingIcon != null)
|
||||
LeadingWidget(
|
||||
leadingIconSize: widget.leadingIconSize,
|
||||
leadingIcon: widget.leadingIcon,
|
||||
leadingIconColor: widget.leadingIconColor,
|
||||
leadingIconWidget: widget.leadingIconWidget,
|
||||
),
|
||||
widget.captionedTextWidget,
|
||||
if (widget.expandableController != null)
|
||||
ExpansionTrailingIcon(
|
||||
@@ -217,11 +220,9 @@ class _MenuItemWidgetState extends State<MenuItemWidget> {
|
||||
return;
|
||||
}
|
||||
_debouncer.run(
|
||||
() => Future(
|
||||
() {
|
||||
executionStateNotifier.value = ExecutionState.inProgress;
|
||||
},
|
||||
),
|
||||
() => Future(() {
|
||||
executionStateNotifier.value = ExecutionState.inProgress;
|
||||
}),
|
||||
);
|
||||
await widget.onTap?.call().then(
|
||||
(value) {
|
||||
|
||||
@@ -15,6 +15,7 @@ class TitleBarWidget extends StatelessWidget {
|
||||
final Color? backgroundColor;
|
||||
final bool isSliver;
|
||||
final double? expandedHeight;
|
||||
final double reducedExpandedHeight;
|
||||
|
||||
const TitleBarWidget({
|
||||
this.leading,
|
||||
@@ -29,6 +30,7 @@ class TitleBarWidget extends StatelessWidget {
|
||||
this.backgroundColor,
|
||||
this.isSliver = true,
|
||||
this.expandedHeight,
|
||||
this.reducedExpandedHeight = 0,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -43,8 +45,9 @@ class TitleBarWidget extends StatelessWidget {
|
||||
leadingWidth: 48,
|
||||
automaticallyImplyLeading: false,
|
||||
pinned: true,
|
||||
expandedHeight:
|
||||
expandedHeight ?? (isFlexibleSpaceDisabled ? toolbarHeight : 102),
|
||||
expandedHeight: expandedHeight ??
|
||||
(isFlexibleSpaceDisabled ? toolbarHeight : 102) -
|
||||
reducedExpandedHeight,
|
||||
centerTitle: false,
|
||||
titleSpacing: 4,
|
||||
title: TitleWidget(
|
||||
|
||||
@@ -106,12 +106,14 @@ class _ToggleSwitchWidgetState extends State<ToggleSwitchWidget> {
|
||||
}
|
||||
|
||||
Future<void> _onChanged(bool negationOfToggleValue) async {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
toggleValue = negationOfToggleValue;
|
||||
//start showing inProgress statu icons if toggle takes more than debounce time
|
||||
_debouncer.run(
|
||||
() => Future(
|
||||
() {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
executionState = ExecutionState.inProgress;
|
||||
});
|
||||
@@ -129,16 +131,16 @@ class _ToggleSwitchWidgetState extends State<ToggleSwitchWidget> {
|
||||
_debouncer.cancelDebounceTimer();
|
||||
|
||||
final newValue = widget.value.call();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
if (toggleValue == newValue) {
|
||||
if (executionState == ExecutionState.inProgress) {
|
||||
executionState = ExecutionState.successful;
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
executionState = ExecutionState.idle;
|
||||
});
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
executionState = ExecutionState.idle;
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
||||
126
mobile/apps/photos/lib/ui/feed/feed_tab.dart
Normal file
126
mobile/apps/photos/lib/ui/feed/feed_tab.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/models/feed/feed_models.dart';
|
||||
import 'package:photos/services/feed/feed_data_service.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/feed/feed_viewer_page.dart';
|
||||
import 'package:photos/ui/feed/notifications_page.dart';
|
||||
import 'package:photos/ui/feed/widgets/feed_item_widget.dart';
|
||||
import 'package:photos/utils/navigation_util.dart';
|
||||
|
||||
class FeedTab extends StatefulWidget {
|
||||
const FeedTab({super.key});
|
||||
|
||||
@override
|
||||
State<FeedTab> createState() => _FeedTabState();
|
||||
}
|
||||
|
||||
class _FeedTabState extends State<FeedTab> {
|
||||
List<FeedItem> _feedItems = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadFeedItems();
|
||||
}
|
||||
|
||||
void _loadFeedItems() {
|
||||
setState(() {
|
||||
_feedItems = FeedDataService.getMockFeedItems();
|
||||
});
|
||||
}
|
||||
|
||||
void _onLike(int index) {
|
||||
setState(() {
|
||||
final item = _feedItems[index];
|
||||
_feedItems[index] = item.copyWith(
|
||||
isLiked: !item.isLiked,
|
||||
likeCount: item.isLiked ? item.likeCount - 1 : item.likeCount + 1,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _onFeedItemTap(FeedItem item) {
|
||||
routeToPage(
|
||||
context,
|
||||
FeedViewerPage(
|
||||
feedItem: item,
|
||||
onLike: () {
|
||||
final index = _feedItems.indexWhere((f) => f.id == item.id);
|
||||
if (index != -1) {
|
||||
_onLike(index);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onNotificationsTap() {
|
||||
routeToPage(context, const NotificationsPage());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.backgroundBase,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 32),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Feed',
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: _onNotificationsTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(
|
||||
Icons.notifications_outlined,
|
||||
size: 28,
|
||||
color: colorScheme.textBase,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Feed content
|
||||
Expanded(
|
||||
child: _feedItems.isEmpty
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
: ListView.builder(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
|
||||
itemCount: _feedItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _feedItems[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: FeedItemWidget(
|
||||
item: item,
|
||||
onTap: () => _onFeedItemTap(item),
|
||||
onLike: () => _onLike(index),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
322
mobile/apps/photos/lib/ui/feed/feed_viewer_page.dart
Normal file
322
mobile/apps/photos/lib/ui/feed/feed_viewer_page.dart
Normal file
@@ -0,0 +1,322 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/models/feed/feed_models.dart';
|
||||
import 'package:photos/ui/feed/notifications_page.dart';
|
||||
import 'package:photos/ui/feed/widgets/feed_user_avatar.dart';
|
||||
import 'package:photos/utils/navigation_util.dart';
|
||||
|
||||
class FeedViewerPage extends StatefulWidget {
|
||||
final FeedItem feedItem;
|
||||
final VoidCallback? onLike;
|
||||
|
||||
const FeedViewerPage({
|
||||
required this.feedItem,
|
||||
this.onLike,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FeedViewerPage> createState() => _FeedViewerPageState();
|
||||
}
|
||||
|
||||
class _FeedViewerPageState extends State<FeedViewerPage> {
|
||||
late PageController _pageController;
|
||||
int _currentPhotoIndex = 0;
|
||||
late bool _isLiked;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageController = PageController();
|
||||
_isLiked = widget.feedItem.isLiked;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onLike() {
|
||||
setState(() {
|
||||
_isLiked = !_isLiked;
|
||||
});
|
||||
widget.onLike?.call();
|
||||
}
|
||||
|
||||
void _onNotificationsTap() {
|
||||
routeToPage(context, const NotificationsPage());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
// Main content
|
||||
_buildMainContent(),
|
||||
// Top bar
|
||||
_buildTopBar(),
|
||||
// Bottom overlay
|
||||
_buildBottomOverlay(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMainContent() {
|
||||
if (widget.feedItem.photos.length == 1) {
|
||||
return _buildSinglePhoto();
|
||||
} else {
|
||||
return _buildMultiplePhotos();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSinglePhoto() {
|
||||
final photo = widget.feedItem.photos.first;
|
||||
return Center(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: photo.url,
|
||||
fit: BoxFit.contain,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
placeholder: (context, url) => const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => const Center(
|
||||
child: Icon(
|
||||
Icons.error,
|
||||
color: Colors.white,
|
||||
size: 50,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMultiplePhotos() {
|
||||
return PageView.builder(
|
||||
controller: _pageController,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
_currentPhotoIndex = index;
|
||||
});
|
||||
},
|
||||
itemCount: widget.feedItem.photos.length,
|
||||
itemBuilder: (context, index) {
|
||||
final photo = widget.feedItem.photos[index];
|
||||
return Center(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: photo.url,
|
||||
fit: BoxFit.contain,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
placeholder: (context, url) => const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => const Center(
|
||||
child: Icon(
|
||||
Icons.error,
|
||||
color: Colors.white,
|
||||
size: 50,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopBar() {
|
||||
return SafeArea(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: const Icon(
|
||||
Icons.arrow_back,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Feed',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: _onNotificationsTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: const Icon(
|
||||
Icons.notifications_outlined,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomOverlay() {
|
||||
return Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black54,
|
||||
Colors.black87,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Photo indicators for multiple photos
|
||||
if (widget.feedItem.photos.length > 1) ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(
|
||||
widget.feedItem.photos.length,
|
||||
(index) => Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: index == _currentPhotoIndex
|
||||
? Colors.white
|
||||
: Colors.white.withOpacity(0.4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
// Action buttons
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// User info and description
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
FeedUserAvatar(
|
||||
avatarUrl: widget.feedItem.user.avatarUrl,
|
||||
name: widget.feedItem.user.name,
|
||||
size: 40,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
widget.feedItem.user.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (widget.feedItem.photos.first.description != null)
|
||||
Text(
|
||||
widget.feedItem.photos.first.description!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Action buttons
|
||||
Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _onLike,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Icon(
|
||||
_isLiked ? Icons.favorite : Icons.favorite_border,
|
||||
color: _isLiked ? Colors.red : Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: const Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: const Icon(
|
||||
Icons.share_outlined,
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Progress bar (placeholder)
|
||||
Container(
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
199
mobile/apps/photos/lib/ui/feed/notifications_page.dart
Normal file
199
mobile/apps/photos/lib/ui/feed/notifications_page.dart
Normal file
@@ -0,0 +1,199 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/models/feed/feed_models.dart';
|
||||
import 'package:photos/services/feed/feed_data_service.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/feed/widgets/feed_user_avatar.dart';
|
||||
|
||||
class NotificationsPage extends StatefulWidget {
|
||||
const NotificationsPage({super.key});
|
||||
|
||||
@override
|
||||
State<NotificationsPage> createState() => _NotificationsPageState();
|
||||
}
|
||||
|
||||
class _NotificationsPageState extends State<NotificationsPage> {
|
||||
List<NotificationItem> _unreadNotifications = [];
|
||||
List<NotificationItem> _readNotifications = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadNotifications();
|
||||
}
|
||||
|
||||
void _loadNotifications() {
|
||||
final notifications = FeedDataService.getMockNotifications();
|
||||
setState(() {
|
||||
_unreadNotifications = notifications.where((n) => !n.isRead).toList();
|
||||
_readNotifications = notifications.where((n) => n.isRead).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.backgroundBase,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(
|
||||
Icons.arrow_back,
|
||||
color: colorScheme.textBase,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Content
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Unread section
|
||||
if (_unreadNotifications.isNotEmpty) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
child: Text(
|
||||
'Unread',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
..._unreadNotifications.map((notification) =>
|
||||
_buildNotificationItem(notification, colorScheme),),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
// Read section
|
||||
if (_readNotifications.isNotEmpty) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
child: Text(
|
||||
'Read',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
..._readNotifications.map((notification) =>
|
||||
_buildNotificationItem(notification, colorScheme),),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotificationItem(NotificationItem notification, colorScheme) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// User avatar
|
||||
FeedUserAvatar(
|
||||
avatarUrl: notification.user.avatarUrl,
|
||||
name: notification.user.name,
|
||||
size: 50,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Notification details
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: colorScheme.textBase,
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: notification.user.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' ${notification.action}',
|
||||
style: TextStyle(
|
||||
color: colorScheme.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
notification.timeAgo,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: colorScheme.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Photo thumbnail if available
|
||||
if (notification.photo != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: notification.photo!.url,
|
||||
width: 60,
|
||||
height: 60,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: Colors.grey[300],
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Colors.grey[300],
|
||||
child: const Center(
|
||||
child: Icon(Icons.error),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
455
mobile/apps/photos/lib/ui/feed/widgets/feed_item_widget.dart
Normal file
455
mobile/apps/photos/lib/ui/feed/widgets/feed_item_widget.dart
Normal file
@@ -0,0 +1,455 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/models/feed/feed_models.dart';
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/feed/widgets/feed_user_avatar.dart';
|
||||
|
||||
class FeedItemWidget extends StatelessWidget {
|
||||
final FeedItem item;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onLike;
|
||||
|
||||
const FeedItemWidget({
|
||||
required this.item,
|
||||
this.onTap,
|
||||
this.onLike,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.06),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// User header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
FeedUserAvatar(
|
||||
avatarUrl: item.user.avatarUrl,
|
||||
name: item.user.name,
|
||||
size: 40,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.user.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 16,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
item.subtitle,
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
item.title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
color: Colors.black87,
|
||||
height: 1.1,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: onLike,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(
|
||||
item.isLiked ? Icons.favorite : Icons.favorite_border,
|
||||
color: item.isLiked ? Colors.red : colorScheme.textMuted,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Content
|
||||
_buildContent(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context) {
|
||||
if (item.photos.length == 1) {
|
||||
return _buildSinglePhoto(context);
|
||||
} else if (item.type == FeedItemType.memory) {
|
||||
return _buildMemoryCarousel(context);
|
||||
} else {
|
||||
return _buildMultiplePhotos(context);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSinglePhoto(BuildContext context) {
|
||||
final photo = item.photos.first;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 400,
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: photo.url,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: Colors.grey[100],
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Colors.grey[100],
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.image_outlined,
|
||||
size: 48,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Image unavailable',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Title overlay for memories and albums
|
||||
if (item.type == FeedItemType.memory || item.type == FeedItemType.album)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(16),
|
||||
bottomRight: Radius.circular(16),
|
||||
),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black38,
|
||||
Colors.black54,
|
||||
],
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
item.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: Offset(0, 1),
|
||||
blurRadius: 3,
|
||||
color: Colors.black26,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Video play button
|
||||
if (item.isVideo)
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black54,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow,
|
||||
color: Colors.white,
|
||||
size: 30,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMultiplePhotos(BuildContext context) {
|
||||
final photosToShow = item.photos.take(3).toList();
|
||||
final remainingCount = item.photos.length > 3 ? item.photos.length - 3 : 0;
|
||||
|
||||
return Container(
|
||||
height: 300,
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// Top row - 2 images side by side
|
||||
if (photosToShow.length >= 2)
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildPhotoItem(photosToShow[0], false, 0),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Expanded(
|
||||
child: _buildPhotoItem(photosToShow[1], false, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Bottom row - 1 image full width (if 3+ photos)
|
||||
if (photosToShow.length >= 3) ...[
|
||||
const SizedBox(height: 2),
|
||||
Expanded(
|
||||
child: _buildPhotoItem(
|
||||
photosToShow[2],
|
||||
remainingCount > 0,
|
||||
remainingCount,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMemoryCarousel(BuildContext context) {
|
||||
final photo = item.photos.first;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 400,
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: photo.url,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: Colors.grey[100],
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Colors.grey[100],
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.image_outlined,
|
||||
size: 48,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Image unavailable',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Title overlay for memories
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(16),
|
||||
bottomRight: Radius.circular(16),
|
||||
),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black38,
|
||||
Colors.black54,
|
||||
],
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
item.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: Offset(0, 1),
|
||||
blurRadius: 3,
|
||||
color: Colors.black26,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Carousel indicator
|
||||
Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'1/${item.photos.length}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhotoItem(FeedPhoto photo, bool showOverlay, int remainingCount) {
|
||||
return Stack(
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: photo.url,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: Colors.grey[100],
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Colors.grey[100],
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.image_outlined,
|
||||
size: 24,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Image unavailable',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 8,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Overlay for remaining photos count
|
||||
if (showOverlay)
|
||||
Container(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'+$remainingCount',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
56
mobile/apps/photos/lib/ui/feed/widgets/feed_user_avatar.dart
Normal file
56
mobile/apps/photos/lib/ui/feed/widgets/feed_user_avatar.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FeedUserAvatar extends StatelessWidget {
|
||||
final String avatarUrl;
|
||||
final double size;
|
||||
final String name;
|
||||
|
||||
const FeedUserAvatar({
|
||||
required this.avatarUrl,
|
||||
this.size = 40.0,
|
||||
required this.name,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
child: ClipOval(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: avatarUrl,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: Colors.grey[300],
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
color: Colors.grey[600],
|
||||
size: size * 0.6,
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Colors.grey[300],
|
||||
child: Center(
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
fontSize: size * 0.4,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ class _HomeBottomNavigationBarState extends State<HomeBottomNavigationBar> {
|
||||
),
|
||||
GButton(
|
||||
margin: const EdgeInsets.fromLTRB(10, 6, 8, 6),
|
||||
icon: Icons.people_outlined,
|
||||
icon: Icons.dynamic_feed_outlined,
|
||||
iconColor: enteColorScheme.tabIcon,
|
||||
iconActiveColor: strokeBaseLight,
|
||||
text: '',
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import "package:photos/core/error-reporting/super_logging.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import "package:photos/service_locator.dart";
|
||||
import "package:photos/services/video_preview_service.dart";
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
|
||||
import 'package:photos/ui/components/captioned_text_widget.dart';
|
||||
@@ -12,6 +11,7 @@ import 'package:photos/ui/components/title_bar_widget.dart';
|
||||
import "package:photos/ui/components/toggle_switch_widget.dart";
|
||||
import "package:photos/ui/settings/app_icon_selection_screen.dart";
|
||||
import "package:photos/ui/settings/ml/machine_learning_settings_page.dart";
|
||||
import "package:photos/ui/settings/streaming/video_streaming_settings_page.dart";
|
||||
import 'package:photos/utils/navigation_util.dart';
|
||||
|
||||
class AdvancedSettingsScreen extends StatelessWidget {
|
||||
@@ -113,23 +113,21 @@ class AdvancedSettingsScreen extends StatelessWidget {
|
||||
title: AppLocalizations.of(context).videoStreaming,
|
||||
),
|
||||
menuItemColor: colorScheme.fillFaint,
|
||||
trailingWidget: Icon(
|
||||
Icons.chevron_right_outlined,
|
||||
color: colorScheme.strokeBase,
|
||||
),
|
||||
singleBorderRadius: 8,
|
||||
alignCaptionedTextToLeft: true,
|
||||
trailingWidget: ToggleSwitchWidget(
|
||||
value: () => VideoPreviewService
|
||||
.instance.isVideoStreamingEnabled,
|
||||
onChanged: () async {
|
||||
final isEnabled = VideoPreviewService
|
||||
.instance.isVideoStreamingEnabled;
|
||||
|
||||
await VideoPreviewService.instance
|
||||
.setIsVideoStreamingEnabled(!isEnabled);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 24,
|
||||
onTap: () async {
|
||||
// ignore: unawaited_futures
|
||||
routeToPage(
|
||||
context,
|
||||
const VideoStreamingSettingsPage(),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: CaptionedTextWidget(
|
||||
title: AppLocalizations.of(context).crashReporting,
|
||||
|
||||
@@ -196,10 +196,9 @@ class _FreeUpSpaceOptionsScreenState extends State<FreeUpSpaceOptionsScreen> {
|
||||
),
|
||||
if (flagService.enableVectorDb)
|
||||
MenuItemWidget(
|
||||
captionedTextWidget:
|
||||
const CaptionedTextWidget(
|
||||
title:
|
||||
"Similar images", // TODO: lau: extract string
|
||||
captionedTextWidget: CaptionedTextWidget(
|
||||
title: AppLocalizations.of(context)
|
||||
.similarImages,
|
||||
),
|
||||
menuItemColor: colorScheme.fillFaint,
|
||||
trailingWidget: Icon(
|
||||
@@ -220,11 +219,11 @@ class _FreeUpSpaceOptionsScreenState extends State<FreeUpSpaceOptionsScreen> {
|
||||
},
|
||||
),
|
||||
if (flagService.enableVectorDb)
|
||||
const Align(
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: MenuSectionDescriptionWidget(
|
||||
content:
|
||||
"Use AI to find images that look similar to each other.", // TODO: lau: extract string
|
||||
content: AppLocalizations.of(context)
|
||||
.useMLToFindSimilarImages,
|
||||
),
|
||||
),
|
||||
if (flagService.enableVectorDb)
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import "dart:async";
|
||||
|
||||
import "package:flutter/gestures.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:photos/core/event_bus.dart";
|
||||
import "package:photos/events/video_preview_state_changed_event.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import "package:photos/l10n/l10n.dart";
|
||||
import "package:photos/models/preview/preview_item_status.dart";
|
||||
import "package:photos/services/video_preview_service.dart";
|
||||
import "package:photos/theme/ente_theme.dart";
|
||||
import "package:photos/ui/common/loading_widget.dart";
|
||||
import "package:photos/ui/common/web_page.dart";
|
||||
import "package:photos/ui/components/buttons/button_widget.dart";
|
||||
import "package:photos/ui/components/captioned_text_widget.dart";
|
||||
import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart";
|
||||
import "package:photos/ui/components/models/button_type.dart";
|
||||
import "package:photos/ui/components/title_bar_title_widget.dart";
|
||||
import "package:photos/ui/components/title_bar_widget.dart";
|
||||
import "package:photos/ui/components/toggle_switch_widget.dart";
|
||||
|
||||
const helpUrl = "https://help.ente.io/photos/faq/video-streaming";
|
||||
|
||||
class VideoStreamingSettingsPage extends StatefulWidget {
|
||||
const VideoStreamingSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
State<VideoStreamingSettingsPage> createState() =>
|
||||
_VideoStreamingSettingsPageState();
|
||||
}
|
||||
|
||||
class _VideoStreamingSettingsPageState
|
||||
extends State<VideoStreamingSettingsPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasEnabled = VideoPreviewService.instance.isVideoStreamingEnabled;
|
||||
return Scaffold(
|
||||
bottomNavigationBar: !hasEnabled
|
||||
? SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16)
|
||||
.copyWith(bottom: 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.primary,
|
||||
labelText: context.l10n.enable,
|
||||
onTap: () async {
|
||||
await toggleVideoStreaming();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
appBar: hasEnabled
|
||||
? null
|
||||
: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(154),
|
||||
child: TitleBarWidget(
|
||||
reducedExpandedHeight: 16,
|
||||
flexibleSpaceTitle: TitleBarTitleWidget(
|
||||
title: AppLocalizations.of(context).videoStreaming,
|
||||
),
|
||||
actionIcons: const [],
|
||||
isSliver: false,
|
||||
),
|
||||
),
|
||||
body: hasEnabled
|
||||
? CustomScrollView(
|
||||
primary: false,
|
||||
slivers: <Widget>[
|
||||
TitleBarWidget(
|
||||
reducedExpandedHeight: 16,
|
||||
flexibleSpaceTitle: TitleBarTitleWidget(
|
||||
title: AppLocalizations.of(context).videoStreaming,
|
||||
),
|
||||
actionIcons: const [],
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: AppLocalizations.of(context)
|
||||
.videoStreamingDescriptionLine1,
|
||||
),
|
||||
const TextSpan(text: " "),
|
||||
TextSpan(
|
||||
text: AppLocalizations.of(context)
|
||||
.videoStreamingDescriptionLine2,
|
||||
),
|
||||
const TextSpan(text: " "),
|
||||
TextSpan(
|
||||
text: AppLocalizations.of(context).moreDetails,
|
||||
style: TextStyle(
|
||||
color: getEnteColorScheme(context).primary500,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = openHelp,
|
||||
),
|
||||
],
|
||||
),
|
||||
style: getEnteTextTheme(context).mini.copyWith(
|
||||
color: getEnteColorScheme(context).textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
).copyWith(top: 30),
|
||||
child: _getStreamingSettings(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
Image.asset(
|
||||
"assets/enable-streaming-static.png",
|
||||
height: 160,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: AppLocalizations.of(context)
|
||||
.videoStreamingDescriptionLine1,
|
||||
),
|
||||
const TextSpan(text: "\n"),
|
||||
TextSpan(
|
||||
text: AppLocalizations.of(context)
|
||||
.videoStreamingDescriptionLine2,
|
||||
),
|
||||
const TextSpan(text: "\n"),
|
||||
TextSpan(
|
||||
text: AppLocalizations.of(context).moreDetails,
|
||||
style: TextStyle(
|
||||
color: getEnteColorScheme(context).primary500,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = openHelp,
|
||||
),
|
||||
],
|
||||
),
|
||||
style: getEnteTextTheme(context).smallMuted,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 140),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> openHelp() async {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return WebPage(AppLocalizations.of(context).help, helpUrl);
|
||||
},
|
||||
),
|
||||
).ignore();
|
||||
}
|
||||
|
||||
Future<void> toggleVideoStreaming() async {
|
||||
final isEnabled = VideoPreviewService.instance.isVideoStreamingEnabled;
|
||||
|
||||
await VideoPreviewService.instance.setIsVideoStreamingEnabled(!isEnabled);
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Widget _getStreamingSettings(BuildContext context) {
|
||||
final hasEnabled = VideoPreviewService.instance.isVideoStreamingEnabled;
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: CaptionedTextWidget(
|
||||
title: AppLocalizations.of(context).enabled,
|
||||
),
|
||||
trailingWidget: ToggleSwitchWidget(
|
||||
value: () => hasEnabled,
|
||||
onChanged: () async {
|
||||
await toggleVideoStreaming();
|
||||
},
|
||||
),
|
||||
singleBorderRadius: 8,
|
||||
alignCaptionedTextToLeft: true,
|
||||
menuItemColor: colorScheme.fillFaint,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const VideoStreamingStatusWidget(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class VideoStreamingStatusWidget extends StatefulWidget {
|
||||
const VideoStreamingStatusWidget({super.key});
|
||||
|
||||
@override
|
||||
State<VideoStreamingStatusWidget> createState() =>
|
||||
VideoStreamingStatusWidgetState();
|
||||
}
|
||||
|
||||
class VideoStreamingStatusWidgetState
|
||||
extends State<VideoStreamingStatusWidget> {
|
||||
double? _netProcessed;
|
||||
StreamSubscription? _subscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
init();
|
||||
_subscription =
|
||||
Bus.instance.on<VideoPreviewStateChangedEvent>().listen((event) {
|
||||
final status = event.status;
|
||||
|
||||
// Handle different states
|
||||
switch (status) {
|
||||
case PreviewItemStatus.uploaded:
|
||||
init();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
_netProcessed = await VideoPreviewService.instance.getStatus();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
return Column(
|
||||
children: [
|
||||
if (_netProcessed != null)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MenuItemWidget(
|
||||
captionedTextWidget: CaptionedTextWidget(
|
||||
title: AppLocalizations.of(context).processed,
|
||||
),
|
||||
trailingWidget: Text(
|
||||
_netProcessed == 0
|
||||
? '0%'
|
||||
: '${(_netProcessed! * 100.0).toStringAsFixed(2)}%',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
singleBorderRadius: 8,
|
||||
alignCaptionedTextToLeft: true,
|
||||
isGestureDetectorDisabled: true,
|
||||
key: ValueKey("processed_items_" + _netProcessed.toString()),
|
||||
menuItemColor: colorScheme.fillFaint,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Text(
|
||||
AppLocalizations.of(context).videoStreamingNote,
|
||||
style: getEnteTextTheme(context).mini.copyWith(
|
||||
color: getEnteColorScheme(context).textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
const EnteLoadingWidget(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import 'package:photos/ui/collections/collection_action_sheet.dart';
|
||||
import "package:photos/ui/components/buttons/button_widget.dart";
|
||||
import "package:photos/ui/components/models/button_type.dart";
|
||||
import 'package:photos/ui/extents_page_view.dart';
|
||||
import "package:photos/ui/feed/feed_tab.dart";
|
||||
import 'package:photos/ui/home/grant_permissions_widget.dart';
|
||||
import 'package:photos/ui/home/header_widget.dart';
|
||||
import 'package:photos/ui/home/home_bottom_nav_bar.dart';
|
||||
@@ -64,7 +65,6 @@ import 'package:photos/ui/home/start_backup_hook_widget.dart';
|
||||
import 'package:photos/ui/notification/update/change_log_page.dart';
|
||||
import "package:photos/ui/settings/app_update_dialog.dart";
|
||||
import "package:photos/ui/settings_page.dart";
|
||||
import "package:photos/ui/tabs/shared_collections_tab.dart";
|
||||
import "package:photos/ui/tabs/user_collections_tab.dart";
|
||||
import "package:photos/ui/viewer/actions/file_viewer.dart";
|
||||
import "package:photos/ui/viewer/file/detail_page.dart";
|
||||
@@ -87,7 +87,6 @@ class HomeWidget extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HomeWidgetState extends State<HomeWidget> {
|
||||
static const _sharedCollectionTab = SharedCollectionsTab();
|
||||
static const _searchTab = SearchTab();
|
||||
static final _settingsPage = SettingsPage(
|
||||
emailNotifier: UserService.instance.emailValueNotifier,
|
||||
@@ -761,7 +760,7 @@ class _HomeWidgetState extends State<HomeWidget> {
|
||||
selectedFiles: _selectedFiles,
|
||||
),
|
||||
UserCollectionsTab(selectedAlbums: _selectedAlbums),
|
||||
_sharedCollectionTab,
|
||||
const FeedTab(),
|
||||
_searchTab,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import 'dart:async';
|
||||
import "dart:math";
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import "package:photos/core/configuration.dart";
|
||||
import "package:photos/core/constants.dart";
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import "package:photos/events/album_sort_order_change_event.dart";
|
||||
import 'package:photos/events/collection_updated_event.dart';
|
||||
import "package:photos/events/favorites_service_init_complete_event.dart";
|
||||
import 'package:photos/events/local_photos_updated_event.dart';
|
||||
import "package:photos/events/tab_changed_event.dart";
|
||||
import 'package:photos/events/user_logged_out_event.dart';
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import 'package:photos/models/collection/collection.dart';
|
||||
import 'package:photos/models/collection/collection_items.dart';
|
||||
import "package:photos/models/search/generic_search_result.dart";
|
||||
import "package:photos/models/selected_albums.dart";
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import "package:photos/services/search_service.dart";
|
||||
import "package:photos/theme/ente_theme.dart";
|
||||
import "package:photos/ui/collections/album/row_item.dart";
|
||||
import "package:photos/ui/collections/button/archived_button.dart";
|
||||
import "package:photos/ui/collections/button/hidden_button.dart";
|
||||
import "package:photos/ui/collections/button/trash_button.dart";
|
||||
@@ -25,9 +32,14 @@ import "package:photos/ui/collections/flex_grid_view.dart";
|
||||
import 'package:photos/ui/common/loading_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
|
||||
import "package:photos/ui/tabs/section_title.dart";
|
||||
import "package:photos/ui/tabs/shared/all_quick_links_page.dart";
|
||||
import "package:photos/ui/tabs/shared/quick_link_album_item.dart";
|
||||
import "package:photos/ui/viewer/actions/album_selection_overlay_bar.dart";
|
||||
import "package:photos/ui/viewer/actions/delete_empty_albums.dart";
|
||||
import "package:photos/ui/viewer/gallery/collect_photos_card_widget.dart";
|
||||
import "package:photos/ui/viewer/gallery/collection_page.dart";
|
||||
import "package:photos/ui/viewer/gallery/empty_state.dart";
|
||||
import "package:photos/ui/viewer/search_tab/contacts_section.dart";
|
||||
import "package:photos/utils/navigation_util.dart";
|
||||
import "package:photos/utils/standalone/debouncer.dart";
|
||||
|
||||
@@ -50,6 +62,7 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
||||
late StreamSubscription<FavoritesServiceInitCompleteEvent>
|
||||
_favoritesServiceInitCompleteEvent;
|
||||
late StreamSubscription<AlbumSortOrderChangeEvent> _albumSortOrderChangeEvent;
|
||||
late StreamSubscription<TabChangedEvent> _tabChangeEvent;
|
||||
|
||||
String _loadReason = "init";
|
||||
final _scrollController = ScrollController();
|
||||
@@ -59,6 +72,16 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
||||
leading: true,
|
||||
);
|
||||
|
||||
// For shared collections functionality
|
||||
final _canLoadDeferredWidgets = ValueNotifier<bool>(false);
|
||||
final _debouncerForDeferringLoad = Debouncer(
|
||||
const Duration(milliseconds: 500),
|
||||
);
|
||||
static const heroTagPrefix = "outgoing_collection";
|
||||
static const maxThumbnailWidth = 224.0;
|
||||
static const crossAxisSpacing = 8.0;
|
||||
static const horizontalPadding = 16.0;
|
||||
|
||||
static const int _kOnEnteItemLimitCount = 12;
|
||||
@override
|
||||
void initState() {
|
||||
@@ -97,6 +120,27 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
||||
_loadReason = event.reason;
|
||||
setState(() {});
|
||||
});
|
||||
|
||||
_tabChangeEvent = Bus.instance.on<TabChangedEvent>().listen((event) {
|
||||
if (event.selectedIndex == 1) {
|
||||
_debouncerForDeferringLoad.run(() async {
|
||||
_logger.info("Loading deferred widgets in collections tab");
|
||||
if (mounted) {
|
||||
_canLoadDeferredWidgets.value = true;
|
||||
await _tabChangeEvent.cancel();
|
||||
Future.delayed(
|
||||
Duration.zero,
|
||||
() => _debouncerForDeferringLoad.cancelDebounceTimer(),
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
_debouncerForDeferringLoad.cancelDebounceTimer();
|
||||
if (mounted) {
|
||||
_canLoadDeferredWidgets.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -198,6 +242,8 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
||||
enableSelectionMode: true,
|
||||
)
|
||||
: const SliverToBoxAdapter(child: EmptyState()),
|
||||
// Shared Collections Content
|
||||
_buildSharedCollectionsSections(),
|
||||
SliverToBoxAdapter(
|
||||
child: Divider(
|
||||
color: getEnteColorScheme(context).strokeFaint,
|
||||
@@ -236,6 +282,251 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSharedCollectionsSections() {
|
||||
return FutureBuilder<SharedCollections>(
|
||||
future: Future.value(CollectionsService.instance.getSharedCollections()),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return _getSharedCollectionsContent(snapshot.data!);
|
||||
} else if (snapshot.hasError) {
|
||||
_logger.severe(
|
||||
"failed to load shared collections",
|
||||
snapshot.error,
|
||||
snapshot.stackTrace,
|
||||
);
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
} else {
|
||||
return const SliverToBoxAdapter(child: EnteLoadingWidget());
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getSharedCollectionsContent(SharedCollections collections) {
|
||||
const maxQuickLinks = 4;
|
||||
final numberOfQuickLinks = collections.quickLinks.length;
|
||||
final double screenWidth = MediaQuery.sizeOf(context).width;
|
||||
final int albumsCountInRow = max(screenWidth ~/ maxThumbnailWidth, 3);
|
||||
final totalHorizontalPadding = (albumsCountInRow - 1) * crossAxisSpacing;
|
||||
final sideOfThumbnail =
|
||||
(screenWidth - totalHorizontalPadding - horizontalPadding) /
|
||||
albumsCountInRow;
|
||||
const quickLinkTitleHeroTag = "quick_link_title";
|
||||
final SectionTitle sharedWithYou =
|
||||
SectionTitle(title: AppLocalizations.of(context).sharedWithYou);
|
||||
final SectionTitle sharedByYou =
|
||||
SectionTitle(title: AppLocalizations.of(context).sharedByYou);
|
||||
final colorTheme = getEnteColorScheme(context);
|
||||
|
||||
return SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
// Check if we have any shared collections content to show
|
||||
if (collections.incoming.isEmpty &&
|
||||
collections.outgoing.isEmpty &&
|
||||
numberOfQuickLinks == 0)
|
||||
const SizedBox.shrink()
|
||||
else ...[
|
||||
const SizedBox(height: 24),
|
||||
// Incoming Collections (Shared with you)
|
||||
if (collections.incoming.isNotEmpty) ...[
|
||||
SectionOptions(
|
||||
onTap: () {
|
||||
unawaited(
|
||||
routeToPage(
|
||||
context,
|
||||
CollectionListPage(
|
||||
collections.incoming,
|
||||
sectionType: UISectionType.incomingCollections,
|
||||
tag: "incoming",
|
||||
appTitle: sharedWithYou,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
Hero(tag: "incoming", child: sharedWithYou),
|
||||
trailingWidget: IconButtonWidget(
|
||||
icon: Icons.chevron_right,
|
||||
iconButtonType: IconButtonType.secondary,
|
||||
iconColor: colorTheme.blurStrokePressed,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
SizedBox(
|
||||
height: sideOfThumbnail + 46,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: horizontalPadding / 2,
|
||||
),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: horizontalPadding / 2,
|
||||
),
|
||||
child: AlbumRowItemWidget(
|
||||
collections.incoming[index],
|
||||
sideOfThumbnail,
|
||||
tag: "incoming",
|
||||
showFileCount: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: collections.incoming.length,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
// Outgoing Collections (Shared by you)
|
||||
if (collections.outgoing.isNotEmpty) ...[
|
||||
SectionOptions(
|
||||
onTap: () {
|
||||
unawaited(
|
||||
routeToPage(
|
||||
context,
|
||||
CollectionListPage(
|
||||
collections.outgoing,
|
||||
sectionType: UISectionType.outgoingCollections,
|
||||
tag: "outgoing",
|
||||
appTitle: sharedByYou,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
Hero(tag: "outgoing", child: sharedByYou),
|
||||
trailingWidget: IconButtonWidget(
|
||||
icon: Icons.chevron_right,
|
||||
iconButtonType: IconButtonType.secondary,
|
||||
iconColor: colorTheme.blurStrokePressed,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
SizedBox(
|
||||
height: sideOfThumbnail + 46,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: horizontalPadding / 2,
|
||||
),
|
||||
child: AlbumRowItemWidget(
|
||||
collections.outgoing[index],
|
||||
sideOfThumbnail,
|
||||
tag: "outgoing",
|
||||
showFileCount: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: collections.outgoing.length,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
// Quick Links
|
||||
if (numberOfQuickLinks > 0) ...[
|
||||
SectionOptions(
|
||||
onTap: numberOfQuickLinks > maxQuickLinks
|
||||
? () {
|
||||
unawaited(
|
||||
routeToPage(
|
||||
context,
|
||||
AllQuickLinksPage(
|
||||
titleHeroTag: quickLinkTitleHeroTag,
|
||||
quickLinks: collections.quickLinks,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
Hero(
|
||||
tag: quickLinkTitleHeroTag,
|
||||
child: SectionTitle(
|
||||
title: AppLocalizations.of(context).quickLinks,
|
||||
),
|
||||
),
|
||||
trailingWidget: numberOfQuickLinks > maxQuickLinks
|
||||
? IconButtonWidget(
|
||||
icon: Icons.chevron_right,
|
||||
iconButtonType: IconButtonType.secondary,
|
||||
iconColor: colorTheme.blurStrokePressed,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 12,
|
||||
left: 12,
|
||||
right: 12,
|
||||
),
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) {
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
final thumbnail = await CollectionsService
|
||||
.instance
|
||||
.getCover(collections.quickLinks[index]);
|
||||
final page = CollectionPage(
|
||||
CollectionWithThumbnail(
|
||||
collections.quickLinks[index],
|
||||
thumbnail,
|
||||
),
|
||||
tagPrefix: heroTagPrefix,
|
||||
);
|
||||
// ignore: unawaited_futures
|
||||
routeToPage(context, page);
|
||||
},
|
||||
child: QuickLinkAlbumItem(
|
||||
c: collections.quickLinks[index],
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) {
|
||||
return const SizedBox(height: 4);
|
||||
},
|
||||
itemCount: min(numberOfQuickLinks, maxQuickLinks),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
// Contacts Section (deferred loading)
|
||||
ValueListenableBuilder(
|
||||
valueListenable: _canLoadDeferredWidgets,
|
||||
builder: (context, value, _) {
|
||||
return value
|
||||
? FutureBuilder(
|
||||
future: SearchService.instance
|
||||
.getAllContactsSearchResults(kSearchSectionLimit),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ContactsSection(
|
||||
snapshot.data as List<GenericSearchResult>,
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
_logger.severe(
|
||||
"failed to load contacts section",
|
||||
snapshot.error,
|
||||
snapshot.stackTrace,
|
||||
);
|
||||
return const SizedBox.shrink();
|
||||
} else {
|
||||
return const EnteLoadingWidget();
|
||||
}
|
||||
},
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
const CollectPhotosCardWidget(),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_localFilesSubscription.cancel();
|
||||
@@ -245,6 +536,9 @@ class _UserCollectionsTabState extends State<UserCollectionsTab>
|
||||
_scrollController.dispose();
|
||||
_debouncer.cancelDebounceTimer();
|
||||
_albumSortOrderChangeEvent.cancel();
|
||||
_tabChangeEvent.cancel();
|
||||
_debouncerForDeferringLoad.cancelDebounceTimer();
|
||||
_canLoadDeferredWidgets.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -250,7 +250,7 @@ class _DeduplicatePageState extends State<DeduplicatePage> {
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ButtonWidget(
|
||||
labelText: "Unselect all", // TODO: lau: extract string
|
||||
labelText: AppLocalizations.of(context).unselectAll,
|
||||
buttonType: ButtonType.secondary,
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
|
||||
@@ -2,24 +2,26 @@ import "dart:async";
|
||||
|
||||
import "package:flutter/foundation.dart" show kDebugMode;
|
||||
import 'package:flutter/material.dart';
|
||||
import "package:intl/intl.dart";
|
||||
import 'package:logging/logging.dart';
|
||||
import "package:photos/core/configuration.dart";
|
||||
import 'package:photos/core/constants.dart';
|
||||
import "package:photos/generated/l10n.dart";
|
||||
|
||||
import "package:photos/models/file/file.dart";
|
||||
import "package:photos/models/selected_files.dart";
|
||||
import "package:photos/models/similar_files.dart";
|
||||
import "package:photos/service_locator.dart";
|
||||
import "package:photos/services/collections_service.dart";
|
||||
import "package:photos/services/machine_learning/similar_images_service.dart";
|
||||
import "package:photos/theme/colors.dart";
|
||||
import 'package:photos/theme/ente_theme.dart';
|
||||
import 'package:photos/ui/components/action_sheet_widget.dart';
|
||||
import "package:photos/theme/text_style.dart";
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import "package:photos/ui/components/models/button_type.dart";
|
||||
import "package:photos/ui/components/toggle_switch_widget.dart";
|
||||
import "package:photos/ui/viewer/file/detail_page.dart";
|
||||
import "package:photos/ui/viewer/file/thumbnail_widget.dart";
|
||||
|
||||
import "package:photos/ui/viewer/gallery/empty_state.dart";
|
||||
import "package:photos/utils/delete_file_util.dart";
|
||||
import "package:photos/utils/dialog_util.dart";
|
||||
import "package:photos/utils/navigation_util.dart";
|
||||
@@ -38,6 +40,12 @@ enum SortKey {
|
||||
count,
|
||||
}
|
||||
|
||||
enum TabFilter {
|
||||
all,
|
||||
similar,
|
||||
identical,
|
||||
}
|
||||
|
||||
class SimilarImagesPage extends StatefulWidget {
|
||||
final bool debugScreen;
|
||||
|
||||
@@ -50,6 +58,8 @@ class SimilarImagesPage extends StatefulWidget {
|
||||
class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
static const crossAxisCount = 3;
|
||||
static const crossAxisSpacing = 12.0;
|
||||
static const double _similarThreshold = 0.02;
|
||||
static const double _identicalThreshold = 0.0001;
|
||||
|
||||
final _logger = Logger("SimilarImagesPage");
|
||||
bool _isDisposed = false;
|
||||
@@ -57,14 +67,40 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
SimilarImagesPageState _pageState = SimilarImagesPageState.setup;
|
||||
double _distanceThreshold = 0.04; // Default value
|
||||
List<SimilarFiles> _similarFilesList = [];
|
||||
SortKey _sortKey = SortKey.distanceAsc;
|
||||
|
||||
SortKey _sortKey = SortKey.size;
|
||||
bool _exactSearch = false;
|
||||
bool _fullRefresh = false;
|
||||
bool _isSelectionSheetOpen = false;
|
||||
TabFilter _selectedTab = TabFilter.identical;
|
||||
|
||||
late SelectedFiles _selectedFiles;
|
||||
late ValueNotifier<String> _deleteProgress;
|
||||
|
||||
List<SimilarFiles> get _filteredGroups {
|
||||
switch (_selectedTab) {
|
||||
case TabFilter.all:
|
||||
return _similarFilesList;
|
||||
case TabFilter.similar:
|
||||
final filteredGroups = <SimilarFiles>[];
|
||||
for (final group in _similarFilesList) {
|
||||
final distance = group.furthestDistance;
|
||||
if (distance > _identicalThreshold && distance <= _similarThreshold) {
|
||||
filteredGroups.add(group);
|
||||
}
|
||||
}
|
||||
return filteredGroups;
|
||||
case TabFilter.identical:
|
||||
final filteredGroups = <SimilarFiles>[];
|
||||
for (final group in _similarFilesList) {
|
||||
final distance = group.furthestDistance;
|
||||
if (distance <= _identicalThreshold) {
|
||||
filteredGroups.add(group);
|
||||
}
|
||||
}
|
||||
return filteredGroups;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -89,7 +125,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
elevation: 0,
|
||||
title: const Text("Similar images"), // TODO: lau: extract string
|
||||
title: Text(AppLocalizations.of(context).similarImages),
|
||||
actions: _pageState == SimilarImagesPageState.results
|
||||
? [_getSortMenu()]
|
||||
: null,
|
||||
@@ -120,7 +156,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
final textTheme = getEnteTextTheme(context);
|
||||
|
||||
return Container(
|
||||
color: colorScheme.backgroundBase.withOpacity(0.8),
|
||||
color: colorScheme.backgroundBase.withValues(alpha: 0.8),
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding:
|
||||
@@ -150,7 +186,8 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
"Deleting... $value", // TODO: lau: extract string
|
||||
AppLocalizations.of(context)
|
||||
.deletingProgress(progress: value),
|
||||
style: textTheme.body,
|
||||
),
|
||||
],
|
||||
@@ -181,24 +218,24 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
"Find similar images", // TODO: lau: extract string
|
||||
AppLocalizations.of(context).findSimilarImages,
|
||||
style: textTheme.h3Bold,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
"Use AI to find images that look similar to each other. Adjust the distance threshold below.", // TODO: lau: extract string
|
||||
"Use AI to find images that look similar to each other. Adjust the distance threshold below.",
|
||||
style: textTheme.body,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
Text(
|
||||
"Similarity threshold", // TODO: lau: extract string
|
||||
"Similarity threshold",
|
||||
style: textTheme.bodyBold,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"Lower values mean a closer match.", // TODO: lau: extract string
|
||||
"Lower values mean a closer match.",
|
||||
style: textTheme.miniMuted,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -229,7 +266,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
],
|
||||
),
|
||||
Text(
|
||||
"Current: ${_distanceThreshold.toStringAsFixed(2)}", // TODO: lau: extract string
|
||||
"Current: ${_distanceThreshold.toStringAsFixed(2)}",
|
||||
style: textTheme.body,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -273,7 +310,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ButtonWidget(
|
||||
labelText: "Find similar images", // TODO: lau: extract string
|
||||
labelText: AppLocalizations.of(context).findSimilarImages,
|
||||
buttonType: ButtonType.primary,
|
||||
onTap: () async {
|
||||
await _findSimilarImages();
|
||||
@@ -303,12 +340,12 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
"No similar images found", // TODO: lau: extract string
|
||||
AppLocalizations.of(context).noSimilarImagesFound,
|
||||
style: textTheme.h3Bold,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"Your photos look unique", // TODO: lau: extract string
|
||||
AppLocalizations.of(context).yourPhotosLookUnique,
|
||||
style: textTheme.bodyMuted,
|
||||
),
|
||||
],
|
||||
@@ -318,75 +355,125 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_buildTabBar(),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
cacheExtent: 400,
|
||||
itemCount: _similarFilesList.length + 1, // +1 for header
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return RepaintBoundary(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: crossAxisSpacing,
|
||||
vertical: 12,
|
||||
),
|
||||
padding: const EdgeInsets.all(crossAxisSpacing),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.fillFaint,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.photo_library_outlined,
|
||||
size: 20,
|
||||
color: colorScheme.textMuted,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"${_similarFilesList.length} ${_similarFilesList.length == 1 ? 'group' : 'groups'} found", // TODO: lau: extract string
|
||||
style: textTheme.bodyBold,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
"Review and remove similar images", // TODO: lau: extract string
|
||||
style: textTheme.miniMuted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Similar files groups (index - 1 because first item is header)
|
||||
final similarFiles = _similarFilesList[index - 1];
|
||||
return RepaintBoundary(
|
||||
child: _buildSimilarFilesGroup(similarFiles),
|
||||
);
|
||||
},
|
||||
),
|
||||
child: _filteredGroups.isEmpty
|
||||
? EmptyState(
|
||||
text:
|
||||
AppLocalizations.of(context).nothingHereTryAnotherFilter,
|
||||
)
|
||||
: ListView.builder(
|
||||
cacheExtent: 400,
|
||||
itemCount: _filteredGroups.length,
|
||||
itemBuilder: (context, index) {
|
||||
final similarFiles = _filteredGroups[index];
|
||||
return RepaintBoundary(
|
||||
child: _buildSimilarFilesGroup(similarFiles),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
_getBottomActionButtons(),
|
||||
if (_filteredGroups.isNotEmpty) _getBottomActionButtons(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabBar() {
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
final textTheme = getEnteTextTheme(context);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildTabButton(
|
||||
TabFilter.identical,
|
||||
AppLocalizations.of(context).identical,
|
||||
colorScheme,
|
||||
textTheme,
|
||||
),
|
||||
const SizedBox(width: crossAxisSpacing),
|
||||
_buildTabButton(
|
||||
TabFilter.similar,
|
||||
AppLocalizations.of(context).similar,
|
||||
colorScheme,
|
||||
textTheme,
|
||||
),
|
||||
const SizedBox(width: crossAxisSpacing),
|
||||
_buildTabButton(
|
||||
TabFilter.all,
|
||||
AppLocalizations.of(context).all,
|
||||
colorScheme,
|
||||
textTheme,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabButton(
|
||||
TabFilter tab,
|
||||
String label,
|
||||
EnteColorScheme colorScheme,
|
||||
EnteTextTheme textTheme,
|
||||
) {
|
||||
final isSelected = _selectedTab == tab;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _onTabChanged(tab),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primary700 : colorScheme.fillFaint,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: isSelected
|
||||
? textTheme.smallBold.copyWith(color: Colors.white)
|
||||
: textTheme.smallBold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onTabChanged(TabFilter newTab) {
|
||||
setState(() {
|
||||
_selectedTab = newTab;
|
||||
|
||||
final newSelection = <EnteFile>{};
|
||||
for (final group in _filteredGroups) {
|
||||
for (int i = 1; i < group.files.length; i++) {
|
||||
newSelection.add(group.files[i]);
|
||||
}
|
||||
}
|
||||
_selectedFiles.clearAll();
|
||||
_selectedFiles.selectAll(newSelection);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _getBottomActionButtons() {
|
||||
return ListenableBuilder(
|
||||
listenable: _selectedFiles,
|
||||
builder: (context, _) {
|
||||
final selectedCount = _selectedFiles.files.length;
|
||||
final selectedFiles = _selectedFiles.files;
|
||||
final selectedCount = selectedFiles.length;
|
||||
final hasSelectedFiles = selectedCount > 0;
|
||||
|
||||
final eligibleFilteredFiles = <EnteFile>{};
|
||||
for (final group in _filteredGroups) {
|
||||
for (int i = 1; i < group.files.length; i++) {
|
||||
eligibleFilteredFiles.add(group.files[i]);
|
||||
}
|
||||
}
|
||||
|
||||
final selectedFilteredFiles =
|
||||
selectedFiles.intersection(eligibleFilteredFiles);
|
||||
final allFilteredSelected = eligibleFilteredFiles.isNotEmpty &&
|
||||
selectedFilteredFiles.length == eligibleFilteredFiles.length;
|
||||
|
||||
int totalSize = 0;
|
||||
for (final file in _selectedFiles.files) {
|
||||
for (final file in selectedFilteredFiles) {
|
||||
totalSize += file.fileSize ?? 0;
|
||||
}
|
||||
|
||||
@@ -421,22 +508,27 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
),
|
||||
);
|
||||
},
|
||||
child: hasSelectedFiles && !_isSelectionSheetOpen
|
||||
child: hasSelectedFiles
|
||||
? Column(
|
||||
key: const ValueKey('delete_section'),
|
||||
children: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ButtonWidget(
|
||||
labelText:
|
||||
"Delete $selectedCount photos (${formatBytes(totalSize)})", // TODO: lau: extract string
|
||||
labelText: AppLocalizations.of(context)
|
||||
.deletePhotosWithSize(
|
||||
count: NumberFormat()
|
||||
.format(selectedFilteredFiles.length),
|
||||
size: formatBytes(totalSize),
|
||||
),
|
||||
buttonType: ButtonType.critical,
|
||||
shouldSurfaceExecutionStates: false,
|
||||
shouldShowSuccessConfirmation: false,
|
||||
onTap: () async {
|
||||
await _deleteFiles(
|
||||
_selectedFiles.files,
|
||||
selectedFilteredFiles,
|
||||
showDialog: true,
|
||||
showUIFeedback: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -446,28 +538,20 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
)
|
||||
: const SizedBox.shrink(key: ValueKey('no_delete')),
|
||||
),
|
||||
if (!_isSelectionSheetOpen)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ButtonWidget(
|
||||
labelText:
|
||||
"Selection options", // TODO: lau: extract string
|
||||
buttonType: ButtonType.secondary,
|
||||
shouldSurfaceExecutionStates: false,
|
||||
shouldShowSuccessConfirmation: false,
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
_isSelectionSheetOpen = true;
|
||||
});
|
||||
await _showSelectionOptionsSheet();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSelectionSheetOpen = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ButtonWidget(
|
||||
labelText: allFilteredSelected
|
||||
? AppLocalizations.of(context).unselectAll
|
||||
: AppLocalizations.of(context).selectAll,
|
||||
buttonType: ButtonType.secondary,
|
||||
shouldSurfaceExecutionStates: false,
|
||||
shouldShowSuccessConfirmation: false,
|
||||
onTap: () async {
|
||||
_toggleSelectAll();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -476,6 +560,25 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleSelectAll() {
|
||||
final eligibleFiles = <EnteFile>{};
|
||||
for (final group in _filteredGroups) {
|
||||
for (int i = 1; i < group.files.length; i++) {
|
||||
eligibleFiles.add(group.files[i]);
|
||||
}
|
||||
}
|
||||
|
||||
final currentSelected = _selectedFiles.files.intersection(eligibleFiles);
|
||||
final allSelected = eligibleFiles.isNotEmpty &&
|
||||
currentSelected.length == eligibleFiles.length;
|
||||
|
||||
if (allSelected) {
|
||||
_selectedFiles.unSelectAll(eligibleFiles);
|
||||
} else {
|
||||
_selectedFiles.selectAll(eligibleFiles);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _findSimilarImages() async {
|
||||
if (_isDisposed) return;
|
||||
setState(() {
|
||||
@@ -483,7 +586,6 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
});
|
||||
|
||||
try {
|
||||
// You can use _toggleValue here for advanced mode features
|
||||
_logger.info("exact mode: $_exactSearch");
|
||||
|
||||
final similarFiles = await SimilarImagesService.instance.getSimilarFiles(
|
||||
@@ -499,6 +601,14 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
_pageState = SimilarImagesPageState.results;
|
||||
_sortSimilarFiles();
|
||||
|
||||
for (final group in _similarFilesList) {
|
||||
if (group.files.length > 1) {
|
||||
for (int i = 1; i < group.files.length; i++) {
|
||||
_selectedFiles.toggleSelection(group.files[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_isDisposed) return;
|
||||
setState(() {});
|
||||
|
||||
@@ -551,114 +661,6 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _selectFilesByThreshold(double threshold) {
|
||||
final filesToSelect = <EnteFile>{};
|
||||
|
||||
for (final similarFilesGroup in _similarFilesList) {
|
||||
if (similarFilesGroup.furthestDistance <= threshold) {
|
||||
for (int i = 1; i < similarFilesGroup.files.length; i++) {
|
||||
filesToSelect.add(similarFilesGroup.files[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filesToSelect.isNotEmpty) {
|
||||
_selectedFiles.clearAll(fireEvent: false);
|
||||
_selectedFiles.selectAll(filesToSelect);
|
||||
} else {
|
||||
_selectedFiles.clearAll(fireEvent: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showSelectionOptionsSheet() async {
|
||||
// Calculate how many files fall into each category
|
||||
int exactFiles = 0;
|
||||
int similarFiles = 0;
|
||||
int allFiles = 0;
|
||||
|
||||
for (final group in _similarFilesList) {
|
||||
final duplicateCount = group.files.length - 1; // Exclude the first file
|
||||
allFiles += duplicateCount;
|
||||
|
||||
if (group.furthestDistance <= 0.0) {
|
||||
exactFiles += duplicateCount;
|
||||
similarFiles += duplicateCount;
|
||||
} else if (group.furthestDistance <= 0.02) {
|
||||
similarFiles += duplicateCount;
|
||||
}
|
||||
}
|
||||
|
||||
final String exactLabel = exactFiles > 0
|
||||
? "Select exact ($exactFiles)" // TODO: lau: extract string
|
||||
: "Select exact"; // TODO: lau: extract string
|
||||
|
||||
final String similarLabel = similarFiles > 0
|
||||
? "Select similar ($similarFiles)" // TODO: lau: extract string
|
||||
: "Select similar"; // TODO: lau: extract string
|
||||
|
||||
final String allLabel = allFiles > 0
|
||||
? "Select all ($allFiles)" // TODO: lau: extract string
|
||||
: "Select all"; // TODO: lau: extract string
|
||||
|
||||
await showActionSheet(
|
||||
context: context,
|
||||
title: "Select similar images", // TODO: lau: extract string
|
||||
body:
|
||||
"Choose which similar images to select for deletion", // TODO: lau: extract string
|
||||
buttons: [
|
||||
ButtonWidget(
|
||||
labelText: exactLabel,
|
||||
buttonType: ButtonType.neutral,
|
||||
buttonSize: ButtonSize.large,
|
||||
shouldStickToDarkTheme: true,
|
||||
isInAlert: true,
|
||||
buttonAction: ButtonAction.first,
|
||||
shouldSurfaceExecutionStates: false,
|
||||
onTap: () async {
|
||||
_selectFilesByThreshold(0.0);
|
||||
},
|
||||
),
|
||||
ButtonWidget(
|
||||
labelText: similarLabel,
|
||||
buttonType: ButtonType.neutral,
|
||||
buttonSize: ButtonSize.large,
|
||||
shouldStickToDarkTheme: true,
|
||||
isInAlert: true,
|
||||
buttonAction: ButtonAction.second,
|
||||
shouldSurfaceExecutionStates: false,
|
||||
onTap: () async {
|
||||
_selectFilesByThreshold(0.02);
|
||||
},
|
||||
),
|
||||
ButtonWidget(
|
||||
labelText: allLabel,
|
||||
buttonType: ButtonType.neutral,
|
||||
buttonSize: ButtonSize.large,
|
||||
shouldStickToDarkTheme: true,
|
||||
isInAlert: true,
|
||||
buttonAction: ButtonAction.third,
|
||||
shouldSurfaceExecutionStates: false,
|
||||
onTap: () async {
|
||||
_selectFilesByThreshold(0.05);
|
||||
},
|
||||
),
|
||||
ButtonWidget(
|
||||
labelText: "Clear selection", // TODO: lau: extract string
|
||||
buttonType: ButtonType.secondary,
|
||||
buttonSize: ButtonSize.large,
|
||||
shouldStickToDarkTheme: true,
|
||||
isInAlert: true,
|
||||
buttonAction: ButtonAction.cancel,
|
||||
shouldSurfaceExecutionStates: false,
|
||||
onTap: () async {
|
||||
_selectedFiles.clearAll(fireEvent: false);
|
||||
},
|
||||
),
|
||||
],
|
||||
actionSheetType: ActionSheetType.defaultActionSheet,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSimilarFilesGroup(SimilarFiles similarFiles) {
|
||||
final textTheme = getEnteTextTheme(context);
|
||||
return Padding(
|
||||
@@ -670,10 +672,11 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"${similarFiles.files.length} similar images" +
|
||||
AppLocalizations.of(context)
|
||||
.similarImagesCount(count: similarFiles.files.length) +
|
||||
(kDebugMode
|
||||
? " (I: d: ${similarFiles.furthestDistance.toStringAsFixed(3)})"
|
||||
: ""), // TODO: lau: extract string
|
||||
: ""),
|
||||
style: textTheme.smallMuted.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
@@ -745,46 +748,23 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
listenable: _selectedFiles,
|
||||
builder: (context, _) {
|
||||
final bool isSelected = _selectedFiles.isFileSelected(file);
|
||||
final bool hasAnySelection = _selectedFiles.files.isNotEmpty;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (hasAnySelection) {
|
||||
// If files are selected, tap should toggle selection
|
||||
_selectedFiles.toggleSelection(file);
|
||||
} else {
|
||||
// If no files selected, tap opens detail page
|
||||
routeToPage(
|
||||
context,
|
||||
DetailPage(
|
||||
DetailPageConfiguration(
|
||||
allFiles,
|
||||
index,
|
||||
"similar_images_",
|
||||
mode: DetailPageMode.minimalistic,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
_selectedFiles.toggleSelection(file);
|
||||
},
|
||||
onLongPress: () {
|
||||
if (hasAnySelection) {
|
||||
// If files are selected, long press opens detail page
|
||||
routeToPage(
|
||||
context,
|
||||
DetailPage(
|
||||
DetailPageConfiguration(
|
||||
allFiles,
|
||||
index,
|
||||
"similar_images_",
|
||||
mode: DetailPageMode.minimalistic,
|
||||
),
|
||||
routeToPage(
|
||||
context,
|
||||
DetailPage(
|
||||
DetailPageConfiguration(
|
||||
allFiles,
|
||||
index,
|
||||
"similar_images_",
|
||||
mode: DetailPageMode.minimalistic,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// If no files selected, long press starts selection
|
||||
_selectedFiles.toggleSelection(file);
|
||||
}
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -872,7 +852,11 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
await _deleteFiles(files, showDialog: showDialog);
|
||||
await _deleteFiles(
|
||||
files,
|
||||
showDialog: showDialog,
|
||||
showUIFeedback: false,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
@@ -890,7 +874,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
"Delete (${files.length})", // TODO: lau: extract string
|
||||
AppLocalizations.of(context).deleteWithCount(count: files.length),
|
||||
style: textTheme.smallBold.copyWith(
|
||||
color: colorScheme.warning500,
|
||||
),
|
||||
@@ -904,19 +888,23 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
Future<void> _deleteFiles(
|
||||
Set<EnteFile> filesToDelete, {
|
||||
bool showDialog = true,
|
||||
bool showUIFeedback = true,
|
||||
}) async {
|
||||
if (filesToDelete.isEmpty) return;
|
||||
if (showDialog) {
|
||||
final _ = await showChoiceActionSheet(
|
||||
context,
|
||||
title: "Delete files", // TODO: lau: extract string
|
||||
body:
|
||||
"Are you sure you want to delete these files?", // TODO: lau: extract string
|
||||
title: AppLocalizations.of(context).deleteFiles,
|
||||
body: AppLocalizations.of(context).areYouSureDeleteFiles,
|
||||
firstButtonLabel: AppLocalizations.of(context).delete,
|
||||
isCritical: true,
|
||||
firstButtonOnTap: () async {
|
||||
try {
|
||||
await _deleteFilesLogic(filesToDelete, true);
|
||||
await _deleteFilesLogic(
|
||||
filesToDelete,
|
||||
true,
|
||||
showUIFeedback: showUIFeedback,
|
||||
);
|
||||
} catch (e, s) {
|
||||
_logger.severe("Failed to delete files", e, s);
|
||||
if (flagService.internalUser) {
|
||||
@@ -926,18 +914,23 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await _deleteFilesLogic(filesToDelete, true);
|
||||
await _deleteFilesLogic(
|
||||
filesToDelete,
|
||||
true,
|
||||
showUIFeedback: showUIFeedback,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteFilesLogic(
|
||||
Set<EnteFile> filesToDelete,
|
||||
bool createSymlink,
|
||||
) async {
|
||||
bool createSymlink, {
|
||||
bool showUIFeedback = true,
|
||||
}) async {
|
||||
if (filesToDelete.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final Map<int, List<EnteFile>> collectionToFilesToAddMap = {};
|
||||
final Map<int, Set<EnteFile>> collectionToFilesToAddMap = {};
|
||||
final allDeleteFiles = <EnteFile>{};
|
||||
final groupsToRemove = <SimilarFiles>{};
|
||||
for (final similarGroup in _similarFilesList) {
|
||||
@@ -952,7 +945,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (similarGroup.files.length <= 1) {
|
||||
if (similarGroup.length <= 1) {
|
||||
groupsToRemove.add(similarGroup);
|
||||
}
|
||||
if (groupDeleteFiles.isNotEmpty) {
|
||||
@@ -968,7 +961,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
continue;
|
||||
}
|
||||
if (!collectionToFilesToAddMap.containsKey(collectionID)) {
|
||||
collectionToFilesToAddMap[collectionID] = [];
|
||||
collectionToFilesToAddMap[collectionID] = {};
|
||||
}
|
||||
collectionToFilesToAddMap[collectionID]!.addAll(filesToKeep);
|
||||
}
|
||||
@@ -978,33 +971,45 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
_similarFilesList.remove(group);
|
||||
}
|
||||
|
||||
final int collectionCnt = collectionToFilesToAddMap.keys.length;
|
||||
if (createSymlink) {
|
||||
final int collectionCnt = collectionToFilesToAddMap.keys.length;
|
||||
final userID = Configuration.instance.getUserID();
|
||||
int progress = 0;
|
||||
for (final collectionID in collectionToFilesToAddMap.keys) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (collectionCnt > 0) {
|
||||
if (collectionCnt > 2 && showUIFeedback) {
|
||||
progress++;
|
||||
// calculate progress percentage upto 2 decimal places
|
||||
final double percentage = (progress / collectionCnt) * 100;
|
||||
_deleteProgress.value = '${percentage.toStringAsFixed(1)}%';
|
||||
}
|
||||
await CollectionsService.instance.addSilentlyToCollection(
|
||||
collectionID,
|
||||
collectionToFilesToAddMap[collectionID]!,
|
||||
);
|
||||
// Check permission before attempting to add symlinks
|
||||
final collection =
|
||||
CollectionsService.instance.getCollectionByID(collectionID);
|
||||
if (collection != null && collection.canAutoAdd(userID!)) {
|
||||
await CollectionsService.instance.addSilentlyToCollection(
|
||||
collectionID,
|
||||
collectionToFilesToAddMap[collectionID]!.toList(),
|
||||
);
|
||||
} else {
|
||||
_logger.warning(
|
||||
"Skipping adding symlinks to collection $collectionID due to missing permissions (${collection?.canAutoAdd(userID!) ?? false}) or collection not found. (${collection == null})",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_deleteProgress.value = "";
|
||||
if (collectionCnt > 2 && showUIFeedback) {
|
||||
_deleteProgress.value = "";
|
||||
}
|
||||
|
||||
_selectedFiles.unSelectAll(allDeleteFiles);
|
||||
setState(() {});
|
||||
await deleteFilesFromRemoteOnly(context, allDeleteFiles.toList());
|
||||
|
||||
// Show congratulations popup
|
||||
if (allDeleteFiles.isNotEmpty && mounted) {
|
||||
if (allDeleteFiles.length > 100 && mounted && showUIFeedback) {
|
||||
final int totalSize = allDeleteFiles.fold<int>(
|
||||
0,
|
||||
(sum, file) => sum + (file.fileSize ?? 0),
|
||||
@@ -1035,13 +1040,16 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
"Great job!", // TODO: lau: extract string
|
||||
AppLocalizations.of(context).greatJob,
|
||||
style: textTheme.h3Bold,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"You cleaned up $deletedCount similar ${deletedCount == 1 ? 'image' : 'images'} and freed up ${formatBytes(totalSize)}", // TODO: lau: extract string
|
||||
AppLocalizations.of(context).cleanedUpSimilarImages(
|
||||
count: deletedCount,
|
||||
size: formatBytes(totalSize),
|
||||
),
|
||||
style: textTheme.body,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -1049,7 +1057,7 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ButtonWidget(
|
||||
labelText: "Done", // TODO: lau: extract string
|
||||
labelText: AppLocalizations.of(context).done,
|
||||
buttonType: ButtonType.primary,
|
||||
onTap: () async => Navigator.of(context).pop(),
|
||||
),
|
||||
@@ -1064,55 +1072,25 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
final textTheme = getEnteTextTheme(context);
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
|
||||
Widget sortOptionContent(SortKey key) {
|
||||
Text sortOptionText(SortKey key) {
|
||||
String text;
|
||||
Widget trailing;
|
||||
|
||||
switch (key) {
|
||||
case SortKey.size:
|
||||
text = "Size"; // TODO: lau: extract string
|
||||
trailing = Icon(
|
||||
Icons.arrow_downward,
|
||||
size: 16,
|
||||
color: colorScheme.textMuted,
|
||||
);
|
||||
text = AppLocalizations.of(context).totalSize;
|
||||
break;
|
||||
case SortKey.distanceAsc:
|
||||
text = "Similarity"; // TODO: lau: extract string
|
||||
trailing = Icon(
|
||||
Icons.arrow_downward,
|
||||
size: 16,
|
||||
color: colorScheme.textMuted,
|
||||
);
|
||||
text = AppLocalizations.of(context).similarity;
|
||||
break;
|
||||
case SortKey.distanceDesc:
|
||||
text = "Similarity"; // TODO: lau: extract string
|
||||
trailing = Icon(
|
||||
Icons.arrow_upward,
|
||||
size: 16,
|
||||
color: colorScheme.textMuted,
|
||||
);
|
||||
text = "(I) Similarity ↑";
|
||||
break;
|
||||
case SortKey.count:
|
||||
text = "Count"; // TODO: lau: extract string
|
||||
trailing = Icon(
|
||||
Icons.arrow_downward,
|
||||
size: 16,
|
||||
color: colorScheme.textMuted,
|
||||
);
|
||||
text = AppLocalizations.of(context).count;
|
||||
break;
|
||||
}
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
text,
|
||||
style: textTheme.miniBold,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
trailing,
|
||||
],
|
||||
return Text(
|
||||
text,
|
||||
style: textTheme.miniBold,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1129,15 +1107,29 @@ class _SimilarImagesPageState extends State<SimilarImagesPage> {
|
||||
onSelected: (int index) {
|
||||
if (_isDisposed) return;
|
||||
setState(() {
|
||||
_sortKey = SortKey.values[index];
|
||||
final newKey = SortKey.values[index];
|
||||
if (newKey == _sortKey) {
|
||||
return;
|
||||
} else {
|
||||
_sortKey = newKey;
|
||||
}
|
||||
});
|
||||
_sortSimilarFiles();
|
||||
},
|
||||
itemBuilder: (context) {
|
||||
return List.generate(SortKey.values.length, (index) {
|
||||
final sortKeys = kDebugMode
|
||||
? SortKey.values
|
||||
: SortKey.values
|
||||
.where((key) => key != SortKey.distanceDesc)
|
||||
.toList();
|
||||
return List.generate(sortKeys.length, (index) {
|
||||
final sortKey = sortKeys[index];
|
||||
return PopupMenuItem(
|
||||
value: index,
|
||||
child: sortOptionContent(SortKey.values[index]),
|
||||
value: SortKey.values.indexOf(sortKey),
|
||||
child: Text(
|
||||
sortOptionText(sortKey).data!,
|
||||
style: textTheme.miniBold,
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
@@ -1160,13 +1152,14 @@ class _SimilarImagesLoadingWidgetState extends State<SimilarImagesLoadingWidget>
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<double> _pulseAnimation;
|
||||
int _loadingMessageIndex = 0;
|
||||
final List<String> _loadingMessages = [
|
||||
"Analyzing your photos locally", // TODO: lau: extract string
|
||||
"Finding similar images", // TODO: lau: extract string
|
||||
"Processing visual patterns", // TODO: lau: extract string
|
||||
"Comparing image features", // TODO: lau: extract string
|
||||
"Almost done", // TODO: lau: extract string
|
||||
];
|
||||
|
||||
List<String> get _loadingMessages => [
|
||||
AppLocalizations.of(context).analyzingPhotosLocally,
|
||||
AppLocalizations.of(context).lookingForVisualSimilarities,
|
||||
AppLocalizations.of(context).comparingImageDetails,
|
||||
AppLocalizations.of(context).findingSimilarImages,
|
||||
AppLocalizations.of(context).almostDone,
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -1203,7 +1196,6 @@ class _SimilarImagesLoadingWidgetState extends State<SimilarImagesLoadingWidget>
|
||||
),
|
||||
);
|
||||
|
||||
// Cycle through loading messages
|
||||
_startMessageCycling();
|
||||
}
|
||||
|
||||
@@ -1251,8 +1243,8 @@ class _SimilarImagesLoadingWidgetState extends State<SimilarImagesLoadingWidget>
|
||||
height: 160,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: colorScheme.primary500.withOpacity(
|
||||
_pulseAnimation.value * 0.1,
|
||||
color: colorScheme.primary500.withValues(
|
||||
alpha: _pulseAnimation.value * 0.1,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1275,11 +1267,11 @@ class _SimilarImagesLoadingWidgetState extends State<SimilarImagesLoadingWidget>
|
||||
),
|
||||
gradient: SweepGradient(
|
||||
colors: [
|
||||
colorScheme.primary500.withOpacity(0),
|
||||
colorScheme.primary500.withOpacity(0.3),
|
||||
colorScheme.primary500.withOpacity(0.6),
|
||||
colorScheme.primary500.withValues(alpha: 0),
|
||||
colorScheme.primary500.withValues(alpha: 0.3),
|
||||
colorScheme.primary500.withValues(alpha: 0.6),
|
||||
colorScheme.primary500,
|
||||
colorScheme.primary500.withOpacity(0),
|
||||
colorScheme.primary500.withValues(alpha: 0),
|
||||
],
|
||||
stops: const [0.0, 0.25, 0.5, 0.75, 1.0],
|
||||
),
|
||||
@@ -1337,7 +1329,7 @@ class _SimilarImagesLoadingWidgetState extends State<SimilarImagesLoadingWidget>
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
"Processing locally", // TODO: lau: extract string
|
||||
AppLocalizations.of(context).processingLocally,
|
||||
style: textTheme.miniFaint,
|
||||
),
|
||||
],
|
||||
@@ -1372,8 +1364,8 @@ class _SimilarImagesLoadingWidgetState extends State<SimilarImagesLoadingWidget>
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: colorScheme.primary500.withOpacity(
|
||||
value < 0.5 ? value * 2 : 2 - value * 2,
|
||||
color: colorScheme.primary500.withValues(
|
||||
alpha: value < 0.5 ? value * 2 : 2 - value * 2,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import "package:flutter_svg/flutter_svg.dart";
|
||||
import "package:local_auth/local_auth.dart";
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:media_extension/media_extension.dart';
|
||||
import "package:photos/core/configuration.dart";
|
||||
import "package:photos/core/event_bus.dart";
|
||||
import "package:photos/events/guest_view_event.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
@@ -23,6 +24,7 @@ import "package:photos/services/local_authentication_service.dart";
|
||||
import "package:photos/services/video_preview_service.dart";
|
||||
import "package:photos/theme/ente_theme.dart";
|
||||
import 'package:photos/ui/collections/collection_action_sheet.dart';
|
||||
import "package:photos/ui/common/popup_item.dart";
|
||||
import 'package:photos/ui/notification/toast.dart';
|
||||
import "package:photos/ui/viewer/file_details/favorite_widget.dart";
|
||||
import "package:photos/ui/viewer/file_details/upload_icon_widget.dart";
|
||||
@@ -193,22 +195,13 @@ class FileAppBarState extends State<FileAppBar> {
|
||||
final List<PopupMenuItem> items = [];
|
||||
if (widget.file.isRemoteFile) {
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
EntePopupMenuItem(
|
||||
AppLocalizations.of(context).download,
|
||||
value: 1,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Platform.isAndroid
|
||||
? Icons.download
|
||||
: Icons.cloud_download_outlined,
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
),
|
||||
Text(AppLocalizations.of(context).download),
|
||||
],
|
||||
),
|
||||
icon: Platform.isAndroid
|
||||
? Icons.download
|
||||
: Icons.cloud_download_outlined,
|
||||
iconColor: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -217,24 +210,13 @@ class FileAppBarState extends State<FileAppBar> {
|
||||
final bool isArchived =
|
||||
widget.file.magicMetadata.visibility == archiveVisibility;
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
EntePopupMenuItem(
|
||||
isArchived
|
||||
? AppLocalizations.of(context).unarchive
|
||||
: AppLocalizations.of(context).archive,
|
||||
value: 2,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isArchived ? Icons.unarchive : Icons.archive_outlined,
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
),
|
||||
Text(
|
||||
isArchived
|
||||
? AppLocalizations.of(context).unarchive
|
||||
: AppLocalizations.of(context).archive,
|
||||
),
|
||||
],
|
||||
),
|
||||
icon: isArchived ? Icons.unarchive : Icons.archive_outlined,
|
||||
iconColor: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -242,118 +224,100 @@ class FileAppBarState extends State<FileAppBar> {
|
||||
widget.file.fileType == FileType.livePhoto) &&
|
||||
Platform.isAndroid) {
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
EntePopupMenuItem(
|
||||
AppLocalizations.of(context).setAs,
|
||||
value: 3,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.wallpaper_outlined,
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
),
|
||||
Text(AppLocalizations.of(context).setAs),
|
||||
],
|
||||
),
|
||||
icon: Icons.wallpaper_outlined,
|
||||
iconColor: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (isOwnedByUser && widget.file.isUploaded) {
|
||||
if (!isFileHidden) {
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
EntePopupMenuItem(
|
||||
AppLocalizations.of(context).hide,
|
||||
value: 4,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.visibility_off,
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
),
|
||||
Text(AppLocalizations.of(context).hide),
|
||||
],
|
||||
),
|
||||
icon: Icons.visibility_off,
|
||||
iconColor: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
EntePopupMenuItem(
|
||||
AppLocalizations.of(context).unhide,
|
||||
value: 5,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.visibility,
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
),
|
||||
Text(AppLocalizations.of(context).unhide),
|
||||
],
|
||||
),
|
||||
icon: Icons.visibility,
|
||||
iconColor: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
EntePopupMenuItem(
|
||||
AppLocalizations.of(context).guestView,
|
||||
value: 6,
|
||||
child: Row(
|
||||
children: [
|
||||
SvgPicture.asset(
|
||||
"assets/icons/guest_view_icon.svg",
|
||||
colorFilter: ColorFilter.mode(
|
||||
getEnteColorScheme(context).textBase,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
),
|
||||
Text(AppLocalizations.of(context).guestView),
|
||||
],
|
||||
iconWidget: SvgPicture.asset(
|
||||
"assets/icons/guest_view_icon.svg",
|
||||
colorFilter: ColorFilter.mode(
|
||||
getEnteColorScheme(context).textBase,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (widget.file.isVideo) {
|
||||
// Video streaming options
|
||||
if (_shouldShowCreateStreamOption()) {
|
||||
items.add(
|
||||
EntePopupMenuItem(
|
||||
AppLocalizations.of(context).createStream,
|
||||
value: 8,
|
||||
icon: Icons.video_settings_outlined,
|
||||
iconColor: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_shouldShowRecreateStreamOption()) {
|
||||
items.add(
|
||||
EntePopupMenuItem(
|
||||
AppLocalizations.of(context).recreateStream,
|
||||
value: 9,
|
||||
icon: Icons.refresh_outlined,
|
||||
iconColor: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
EntePopupMenuItem(
|
||||
shouldLoopVideo
|
||||
? AppLocalizations.of(context).loopVideoOn
|
||||
: AppLocalizations.of(context).loopVideoOff,
|
||||
value: 7,
|
||||
child: Row(
|
||||
iconWidget: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.loop_rounded,
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
shouldLoopVideo
|
||||
? const SizedBox.shrink()
|
||||
: Transform.rotate(
|
||||
angle: 3.14 / 4,
|
||||
child: Container(
|
||||
width: 2,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
borderRadius: BorderRadius.circular(1),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
Icon(
|
||||
Icons.loop_rounded,
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
shouldLoopVideo
|
||||
? Text(AppLocalizations.of(context).loopVideoOn)
|
||||
: Text(AppLocalizations.of(context).loopVideoOff),
|
||||
? const SizedBox.shrink()
|
||||
: Transform.rotate(
|
||||
angle: 3.14 / 4,
|
||||
child: Container(
|
||||
width: 2,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
borderRadius: BorderRadius.circular(1),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -392,6 +356,10 @@ class FileAppBarState extends State<FileAppBar> {
|
||||
}
|
||||
} else if (value == 7) {
|
||||
_onToggleLoopVideo();
|
||||
} else if (value == 8) {
|
||||
await _handleVideoStream('create');
|
||||
} else if (value == 9) {
|
||||
await _handleVideoStream('recreate');
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -511,4 +479,52 @@ class FileAppBarState extends State<FileAppBar> {
|
||||
await localSettings.setOnGuestView(false);
|
||||
}
|
||||
}
|
||||
|
||||
bool _shouldShowCreateStreamOption() {
|
||||
// Show "Create Stream" option for uploaded video files without streams
|
||||
return _ensureBasicRequirements() &&
|
||||
!fileDataService.previewIds.containsKey(widget.file.uploadedFileID!);
|
||||
}
|
||||
|
||||
bool _shouldShowRecreateStreamOption() {
|
||||
// Show "Recreate Stream" option for uploaded video files with existing streams
|
||||
return _ensureBasicRequirements() &&
|
||||
fileDataService.previewIds.containsKey(widget.file.uploadedFileID!);
|
||||
}
|
||||
|
||||
bool _ensureBasicRequirements() {
|
||||
// Skip if sv=1 (server indicates streaming not needed)
|
||||
final userId = Configuration.instance.getUserID();
|
||||
return widget.file.fileType == FileType.video &&
|
||||
widget.file.isUploaded &&
|
||||
(widget.file.pubMagicMetadata?.sv ?? 0) != 1 &&
|
||||
widget.file.ownerID == userId;
|
||||
}
|
||||
|
||||
Future<void> _handleVideoStream(String streamType) async {
|
||||
try {
|
||||
final bool wasAdded = await VideoPreviewService.instance
|
||||
.addToManualQueue(widget.file, streamType);
|
||||
|
||||
if (!wasAdded) {
|
||||
// File was already in queue
|
||||
showToast(
|
||||
context,
|
||||
AppLocalizations.of(context).videoAlreadyInQueue,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showToast(context, AppLocalizations.of(context).addedToQueue);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_reloadActions = true;
|
||||
});
|
||||
}
|
||||
} catch (e, s) {
|
||||
_logger.severe("Failed to $streamType video stream", e, s);
|
||||
await showGenericErrorDialog(context: context, error: e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,22 +245,22 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
|
||||
final cachedSmallThumbnail =
|
||||
ThumbnailInMemoryLruCache.get(widget.file, thumbnailSmallSize);
|
||||
if (cachedSmallThumbnail != null) {
|
||||
_imageProvider = Image.memory(
|
||||
final imageProvider = Image.memory(
|
||||
cachedSmallThumbnail,
|
||||
cacheHeight: optimizedImageHeight,
|
||||
cacheWidth: optimizedImageWidth,
|
||||
).image;
|
||||
_hasLoadedThumbnail = true;
|
||||
_cacheAndRender(imageProvider);
|
||||
return;
|
||||
}
|
||||
if (widget.diskLoadDeferDuration != null) {
|
||||
Future.delayed(widget.diskLoadDeferDuration!, () {
|
||||
if (mounted) {
|
||||
_getThumbnailFromDisk();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (widget.diskLoadDeferDuration != null) {
|
||||
Future.delayed(widget.diskLoadDeferDuration!, () {
|
||||
if (mounted) {
|
||||
_getThumbnailFromDisk();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
_getThumbnailFromDisk();
|
||||
}
|
||||
_getThumbnailFromDisk();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -383,12 +383,12 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
|
||||
_isLoadingRemoteThumbnail = true;
|
||||
final cachedThumbnail = ThumbnailInMemoryLruCache.get(widget.file);
|
||||
if (cachedThumbnail != null) {
|
||||
_imageProvider = Image.memory(
|
||||
final imageProvider = Image.memory(
|
||||
cachedThumbnail,
|
||||
cacheHeight: optimizedImageHeight,
|
||||
cacheWidth: optimizedImageWidth,
|
||||
).image;
|
||||
_hasLoadedThumbnail = true;
|
||||
_cacheAndRender(imageProvider);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -432,19 +432,14 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
|
||||
}
|
||||
|
||||
void _cacheAndRender(ImageProvider<Object> imageProvider) {
|
||||
if (imageCache.currentSizeBytes > 256 * 1024 * 1024) {
|
||||
_logger.info("Clearing image cache");
|
||||
imageCache.clear();
|
||||
imageCache.clearLiveImages();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_imageProvider = imageProvider;
|
||||
_hasLoadedThumbnail = true;
|
||||
});
|
||||
}
|
||||
precacheImage(imageProvider, context).then((value) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_imageProvider = imageProvider;
|
||||
_hasLoadedThumbnail = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
precacheImage(imageProvider, context);
|
||||
}
|
||||
|
||||
void _reset() {
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import "dart:async";
|
||||
|
||||
import "package:flutter/material.dart";
|
||||
import "package:photos/core/event_bus.dart";
|
||||
import "package:photos/events/video_preview_state_changed_event.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import "package:photos/models/file/file.dart";
|
||||
import "package:photos/models/preview/preview_item_status.dart";
|
||||
import "package:photos/service_locator.dart";
|
||||
import "package:photos/services/video_preview_service.dart";
|
||||
import "package:photos/theme/colors.dart";
|
||||
import "package:photos/theme/ente_theme.dart" show getEnteColorScheme;
|
||||
|
||||
class VideoStreamChangeWidget extends StatefulWidget {
|
||||
const VideoStreamChangeWidget({
|
||||
@@ -24,13 +31,35 @@ class VideoStreamChangeWidget extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _VideoStreamChangeWidgetState extends State<VideoStreamChangeWidget> {
|
||||
StreamSubscription<VideoPreviewStateChangedEvent>? _subscription;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_subscription =
|
||||
Bus.instance.on<VideoPreviewStateChangedEvent>().listen((event) {
|
||||
final fileId = event.fileId;
|
||||
if (widget.file.uploadedFileID != fileId) {
|
||||
return; // Not for this file
|
||||
}
|
||||
|
||||
final status = event.status;
|
||||
|
||||
// Handle different states
|
||||
switch (status) {
|
||||
case PreviewItemStatus.inQueue:
|
||||
case PreviewItemStatus.uploaded:
|
||||
case PreviewItemStatus.failed:
|
||||
setState(() {});
|
||||
break;
|
||||
default:
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -38,9 +67,19 @@ class _VideoStreamChangeWidgetState extends State<VideoStreamChangeWidget> {
|
||||
Widget build(BuildContext context) {
|
||||
final bool isPreviewAvailable = widget.file.uploadedFileID != null &&
|
||||
(fileDataService.previewIds.containsKey(widget.file.uploadedFileID));
|
||||
if (!isPreviewAvailable) {
|
||||
|
||||
// Check if this file is currently being processed for streaming
|
||||
final bool isCurrentlyProcessing = VideoPreviewService.instance
|
||||
.isCurrentlyProcessing(widget.file.uploadedFileID);
|
||||
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
|
||||
if (!isPreviewAvailable && !isCurrentlyProcessing) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
// If currently processing, show "Creating Stream" with spinner (not clickable)
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: AnimatedOpacity(
|
||||
@@ -54,42 +93,89 @@ class _VideoStreamChangeWidgetState extends State<VideoStreamChangeWidget> {
|
||||
right: 10,
|
||||
bottom: 4,
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onStreamChange,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(200),
|
||||
),
|
||||
border: Border.all(
|
||||
color: strokeFaintDark,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.play_arrow, size: 16),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
widget.isPreviewPlayer
|
||||
? AppLocalizations.of(context).playOriginal
|
||||
: AppLocalizations.of(context).playStream,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
child: isCurrentlyProcessing
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.backdropBase,
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(200),
|
||||
),
|
||||
border: Border.all(
|
||||
color: strokeFaintDark,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
colorScheme.fillBase,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
AppLocalizations.of(context).creatingStream,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.fillBase,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: GestureDetector(
|
||||
onTap: widget.onStreamChange,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(200),
|
||||
),
|
||||
border: Border.all(
|
||||
color: strokeFaintDark,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.play_arrow,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
widget.isPreviewPlayer
|
||||
? AppLocalizations.of(context).playOriginal
|
||||
: AppLocalizations.of(context).playStream,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -53,11 +53,11 @@ const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600);
|
||||
/// {@end-tool}
|
||||
///
|
||||
/// A scrollbar track can be added using [trackVisibility]. This can also be
|
||||
/// drawn when triggered by a hover event, or based on any [MaterialState] by
|
||||
/// drawn when triggered by a hover event, or based on any [WidgetState] by
|
||||
/// using [ScrollbarThemeData.trackVisibility].
|
||||
///
|
||||
/// The [thickness] of the track and scrollbar thumb can be changed dynamically
|
||||
/// in response to [MaterialState]s using [ScrollbarThemeData.thickness].
|
||||
/// in response to [WidgetState]s using [ScrollbarThemeData.thickness].
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
|
||||
@@ -6,7 +6,6 @@ import "package:permission_handler/permission_handler.dart";
|
||||
import "package:photos/db/upload_locks_db.dart";
|
||||
import "package:photos/extensions/stop_watch.dart";
|
||||
import "package:photos/main.dart";
|
||||
import "package:photos/service_locator.dart";
|
||||
import "package:photos/utils/file_uploader.dart";
|
||||
import "package:shared_preferences/shared_preferences.dart";
|
||||
import "package:workmanager/workmanager.dart" as workmanager;
|
||||
@@ -81,7 +80,7 @@ class BgTaskUtils {
|
||||
try {
|
||||
await workmanager.Workmanager().initialize(
|
||||
callbackDispatcher,
|
||||
isInDebugMode: Platform.isIOS && flagService.internalUser,
|
||||
isInDebugMode: false,
|
||||
);
|
||||
await workmanager.Workmanager().registerPeriodicTask(
|
||||
backgroundTaskIdentifier,
|
||||
|
||||
@@ -71,7 +71,7 @@ Future<FFProbeProps?> getVideoPropsAsync(File originalFile) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
||||
final mediaInfo =
|
||||
await IsolatedFfmpegService.getVideoInfo(originalFile.path);
|
||||
await IsolatedFfmpegService.instance.getVideoInfo(originalFile.path);
|
||||
if (mediaInfo.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,14 +9,13 @@ import "package:path_provider/path_provider.dart"
|
||||
show getExternalStorageDirectory;
|
||||
import 'package:photos/models/memories/clip_memory.dart';
|
||||
import 'package:photos/models/memories/people_memory.dart';
|
||||
import 'package:photos/models/ml/discover/prompt.dart';
|
||||
import "package:photos/services/machine_learning/ml_computer.dart"
|
||||
show MLComputer;
|
||||
|
||||
final _logger = Logger('TextEmbeddingsUtil');
|
||||
|
||||
/// Loads pre-computed memories text embeddings from assets
|
||||
Future<MemoriesEmbeddings?> loadMemoriesEmbeddings() async {
|
||||
/// Loads pre-computed text embeddings from assets
|
||||
Future<TextEmbeddings?> loadTextEmbeddingsFromAssets() async {
|
||||
try {
|
||||
_logger.info('Loading text embeddings from assets');
|
||||
final jsonString =
|
||||
@@ -82,7 +81,7 @@ Future<MemoriesEmbeddings?> loadMemoriesEmbeddings() async {
|
||||
}
|
||||
|
||||
_logger.info('Text embeddings loaded successfully from JSON assets');
|
||||
return MemoriesEmbeddings(
|
||||
return TextEmbeddings(
|
||||
clipPositiveVector: clipPositiveVector,
|
||||
peopleActivityVectors: peopleActivityVectors,
|
||||
clipMemoryTypeVectors: clipMemoryTypeVectors,
|
||||
@@ -93,94 +92,18 @@ Future<MemoriesEmbeddings?> loadMemoriesEmbeddings() async {
|
||||
}
|
||||
}
|
||||
|
||||
/// Container for all memories text embeddings
|
||||
class MemoriesEmbeddings {
|
||||
class TextEmbeddings {
|
||||
final Vector clipPositiveVector;
|
||||
final Map<PeopleActivity, Vector> peopleActivityVectors;
|
||||
final Map<ClipMemoryType, Vector> clipMemoryTypeVectors;
|
||||
|
||||
const MemoriesEmbeddings({
|
||||
const TextEmbeddings({
|
||||
required this.clipPositiveVector,
|
||||
required this.peopleActivityVectors,
|
||||
required this.clipMemoryTypeVectors,
|
||||
});
|
||||
}
|
||||
|
||||
/// Loads pre-computed discover text embeddings from assets
|
||||
Future<DiscoverEmbeddings?> loadDiscoverEmbeddings() async {
|
||||
try {
|
||||
_logger.info('Loading discover embeddings from assets');
|
||||
final jsonString =
|
||||
await rootBundle.loadString('assets/ml/discover_embeddings.json');
|
||||
final data = json.decode(jsonString) as Map<String, dynamic>;
|
||||
|
||||
final promptsList = data['prompts'] as List;
|
||||
final Map<String, Vector> queryToVector = {};
|
||||
final Map<String, PromptData> queryToPromptData = {};
|
||||
|
||||
// Parse all discover embeddings
|
||||
for (final promptJson in promptsList) {
|
||||
final query = promptJson['query'] as String;
|
||||
final vector = (promptJson['vector'] as List).cast<double>();
|
||||
if (vector.isNotEmpty) {
|
||||
queryToVector[query] = Vector.fromList(vector);
|
||||
queryToPromptData[query] = PromptData(
|
||||
title: promptJson['title'] as String,
|
||||
minScore: (promptJson['minScore'] as num).toDouble(),
|
||||
minSize: (promptJson['minSize'] as num).toDouble(),
|
||||
showVideo: promptJson['showVideo'] as bool?,
|
||||
recentFirst: promptJson['recentFirst'] as bool?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have all embeddings
|
||||
if (queryToVector.isEmpty) {
|
||||
_logger.warning('No discover embeddings found');
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.info(
|
||||
'Discover embeddings loaded successfully with ${queryToVector.length} queries',
|
||||
);
|
||||
return DiscoverEmbeddings(
|
||||
queryToVector: queryToVector,
|
||||
queryToPromptData: queryToPromptData,
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
_logger.severe('Failed to load discover embeddings', e, stackTrace);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Container for discover/magic embeddings
|
||||
class DiscoverEmbeddings {
|
||||
final Map<String, Vector> queryToVector;
|
||||
final Map<String, PromptData> queryToPromptData;
|
||||
|
||||
const DiscoverEmbeddings({
|
||||
required this.queryToVector,
|
||||
required this.queryToPromptData,
|
||||
});
|
||||
}
|
||||
|
||||
/// Container for prompt metadata
|
||||
class PromptData {
|
||||
final String title;
|
||||
final double minScore;
|
||||
final double minSize;
|
||||
final bool? showVideo;
|
||||
final bool? recentFirst;
|
||||
|
||||
const PromptData({
|
||||
required this.title,
|
||||
required this.minScore,
|
||||
required this.minSize,
|
||||
this.showVideo,
|
||||
this.recentFirst,
|
||||
});
|
||||
}
|
||||
|
||||
/// Helper function to generate text embeddings and save them to a JSON file
|
||||
/// Run this once to generate the embeddings, then copy the output
|
||||
/// to assets/ml/text_embeddings.json
|
||||
@@ -247,161 +170,3 @@ Future<void> generateAndSaveTextEmbeddings() async {
|
||||
'_generateAndSaveTextEmbeddings: Text embeddings generation complete! Copy the JSON output above to assets/ml/text_embeddings.json',
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper function to generate discover text embeddings and save them to a JSON file
|
||||
/// Run this once to generate the embeddings, then copy the output
|
||||
/// to assets/ml/discover_embeddings.json
|
||||
Future<void> generateAndSaveDiscoverEmbeddings() async {
|
||||
// Hardcoded prompts from v2.json
|
||||
final magicPromptsData = [
|
||||
Prompt(
|
||||
query: "identity documents and cards",
|
||||
title: "Identity",
|
||||
minScore: 0.20,
|
||||
minSize: 0.0,
|
||||
showVideo: false,
|
||||
),
|
||||
Prompt(
|
||||
query: "screenshots",
|
||||
title: "Screenshots",
|
||||
minScore: 0.18,
|
||||
minSize: 0.0,
|
||||
recentFirst: true,
|
||||
showVideo: false,
|
||||
),
|
||||
Prompt(
|
||||
query: "receipts, bills and invoices",
|
||||
title: "Receipts",
|
||||
minScore: 0.20,
|
||||
minSize: 0.0,
|
||||
recentFirst: true,
|
||||
),
|
||||
Prompt(
|
||||
query: "screenshots of digital notes, photos of notebook and whiteboards",
|
||||
title: "Notes",
|
||||
minScore: 0.20,
|
||||
minSize: 0.0,
|
||||
recentFirst: true,
|
||||
),
|
||||
Prompt(
|
||||
query: "memes",
|
||||
title: "Memes",
|
||||
minScore: 0.18,
|
||||
minSize: 0.0,
|
||||
recentFirst: true,
|
||||
),
|
||||
Prompt(
|
||||
query: "business card",
|
||||
title: "Visiting Cards",
|
||||
minScore: 0.20,
|
||||
minSize: 0.0,
|
||||
recentFirst: true,
|
||||
showVideo: false,
|
||||
),
|
||||
Prompt(
|
||||
query: "photo of toddler",
|
||||
title: "Babies",
|
||||
minScore: 0.20,
|
||||
minSize: 0.0,
|
||||
recentFirst: true,
|
||||
),
|
||||
Prompt(
|
||||
query: "photos of pets",
|
||||
title: "Pets",
|
||||
minScore: 0.20,
|
||||
minSize: 0.0,
|
||||
recentFirst: true,
|
||||
),
|
||||
Prompt(
|
||||
query: "selfie",
|
||||
title: "Selfies",
|
||||
minScore: 0.18,
|
||||
minSize: 0.0,
|
||||
recentFirst: true,
|
||||
),
|
||||
Prompt(
|
||||
query: "high resolution hd wallpaper",
|
||||
title: "Wallpapers",
|
||||
minScore: 0.18,
|
||||
minSize: 0.0,
|
||||
showVideo: false,
|
||||
),
|
||||
Prompt(
|
||||
query: "photo of food",
|
||||
title: "Food",
|
||||
minScore: 0.20,
|
||||
minSize: 0.0,
|
||||
recentFirst: true,
|
||||
),
|
||||
Prompt(
|
||||
query: "celebration with friends",
|
||||
title: "Celebrations",
|
||||
minScore: 0.20,
|
||||
minSize: 0.0,
|
||||
recentFirst: true,
|
||||
),
|
||||
Prompt(
|
||||
query: "photo of setting sun",
|
||||
title: "Sunset",
|
||||
minScore: 0.20,
|
||||
minSize: 0.0,
|
||||
),
|
||||
Prompt(
|
||||
query: "photo of mountains or hills",
|
||||
title: "Hills",
|
||||
minScore: 0.20,
|
||||
minSize: 0.0,
|
||||
),
|
||||
Prompt(
|
||||
query: "outdoor photo of trees, leaves, flowers",
|
||||
title: "Greenery",
|
||||
minScore: 0.20,
|
||||
minSize: 0.0,
|
||||
),
|
||||
];
|
||||
|
||||
final Map<String, dynamic> embeddingsData = {
|
||||
'version': '1.0.0',
|
||||
'prompts': [],
|
||||
};
|
||||
|
||||
final prompts = <Map<String, dynamic>>[];
|
||||
|
||||
for (final prompt in magicPromptsData) {
|
||||
_logger.info('Generating embedding for discover prompt: ${prompt.title}');
|
||||
final vector = await MLComputer.instance.runClipText(prompt.query);
|
||||
|
||||
final promptData = {
|
||||
'query': prompt.query,
|
||||
'title': prompt.title,
|
||||
'minScore': prompt.minScore,
|
||||
'minSize': prompt.minSize,
|
||||
'vector': vector,
|
||||
};
|
||||
|
||||
// Add optional fields
|
||||
promptData['showVideo'] = prompt.showVideo;
|
||||
promptData['recentFirst'] = prompt.recentFirst;
|
||||
|
||||
prompts.add(promptData);
|
||||
}
|
||||
|
||||
embeddingsData['prompts'] = prompts;
|
||||
|
||||
// Convert to JSON and log it
|
||||
final jsonString = const JsonEncoder.withIndent(' ').convert(embeddingsData);
|
||||
dev.log(
|
||||
'_generateAndSaveDiscoverEmbeddings: Generated discover embeddings JSON',
|
||||
);
|
||||
|
||||
final tempDir = await getExternalStorageDirectory();
|
||||
final file = File('${tempDir!.path}/discover_embeddings.json');
|
||||
await file.writeAsString(jsonString);
|
||||
dev.log(
|
||||
'_generateAndSaveDiscoverEmbeddings: Saved discover embeddings to ${file.path}',
|
||||
);
|
||||
|
||||
dev.log(
|
||||
'_generateAndSaveDiscoverEmbeddings: Discover embeddings generation complete! Copy the JSON output above to assets/ml/discover_embeddings.json',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ class FlagService {
|
||||
|
||||
bool get enableMobMultiPart => flags.enableMobMultiPart || internalUser;
|
||||
|
||||
bool get enableVectorDb => flags.internalUser;
|
||||
bool get enableVectorDb => hasGrantedMLConsent;
|
||||
|
||||
String get castUrl => flags.castUrl;
|
||||
|
||||
|
||||
@@ -29,10 +29,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: "01949bf52ad33f0e0f74f881fbaac4f348c556531951d92c8d16f1262aa19ff8"
|
||||
sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.5.4"
|
||||
version: "7.7.1"
|
||||
android_intent_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -166,10 +166,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build
|
||||
sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7"
|
||||
sha256: "7d95cbbb1526ab5ae977df9b4cc660963b9b27f6d1075c0b34653868911385e4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
version: "3.0.0"
|
||||
build_cli_annotations:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -198,26 +198,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_resolvers
|
||||
sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62
|
||||
sha256: "38c9c339333a09b090a638849a4c56e70a404c6bdd3b511493addfbc113b60c2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
version: "3.0.0"
|
||||
build_runner:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: build_runner
|
||||
sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53"
|
||||
sha256: b971d4a1c789eba7be3e6fe6ce5e5b50fd3719e3cb485b3fad6d04358304351d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
version: "2.6.0"
|
||||
build_runner_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_runner_core
|
||||
sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792"
|
||||
sha256: c04e612ca801cd0928ccdb891c263a2b1391cb27940a5ea5afcf9ba894de5d62
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.1.2"
|
||||
version: "9.2.0"
|
||||
built_collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1170,10 +1170,10 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: freezed
|
||||
sha256: "2d399f823b8849663744d2a9ddcce01c49268fb4170d0442a655bf6a2f47be22"
|
||||
sha256: da32f8ba8cfcd4ec71d9decc8cbf28bd2c31b5283d9887eb51eb4a0659d8110c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
version: "3.2.0"
|
||||
freezed_annotation:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1396,10 +1396,10 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: json_serializable
|
||||
sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c
|
||||
sha256: ce2cf974ccdee13be2a510832d7fba0b94b364e0b0395dee42abaa51b855be27
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.9.5"
|
||||
version: "6.10.0"
|
||||
latlong2:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1669,6 +1669,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.12.6"
|
||||
mockito:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: mockito
|
||||
sha256: "2314cbe9165bcd16106513df9cf3c3224713087f09723b128928dc11a4379f99"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.5.0"
|
||||
modal_bottom_sheet:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -2341,18 +2349,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_gen
|
||||
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
|
||||
sha256: "7b19d6ba131c6eb98bfcbf8d56c1a7002eba438af2e7ae6f8398b2b0f4f381e3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
version: "3.1.0"
|
||||
source_helper:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_helper
|
||||
sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c"
|
||||
sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.5"
|
||||
version: "1.3.7"
|
||||
source_map_stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -3028,5 +3036,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.7.2 <4.0.0"
|
||||
dart: ">=3.8.0 <4.0.0"
|
||||
flutter: ">=3.29.0"
|
||||
|
||||
@@ -269,6 +269,7 @@ dev_dependencies:
|
||||
intl_utils: ^2.8.7
|
||||
json_annotation: ^4.8.0
|
||||
json_serializable: ^6.6.1
|
||||
mockito: ^5.5.0
|
||||
test: ^1.22.0
|
||||
|
||||
# ------------------------------
|
||||
|
||||
@@ -32,8 +32,8 @@ impl VectorDB {
|
||||
|
||||
if file_exists {
|
||||
println!("Loading index from disk.");
|
||||
// Creates a view of the index from a file without loading it into memory. https://docs.rs/usearch/latest/usearch/struct.Index.html#method.view
|
||||
db.index.view(file_path).expect("Failed to load index");
|
||||
// Loads the index into memory. https://docs.rs/usearch/latest/usearch/struct.Index.html#method.load
|
||||
db.index.load(file_path).expect("Failed to load index");
|
||||
} else {
|
||||
println!("Creating new index.");
|
||||
db.save_index();
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
- Prateek: Enable immediate manual video stream processing by bypassing user interaction timer
|
||||
- Prateek: Fix multiple concurrent streaming processes bug in ComputeController
|
||||
- Prateek: Fix video streaming description text display spacing in advanced settings
|
||||
- Ashil: Render cached thumbnails faster (noticeable in gallery scrolling)
|
||||
- Similar images UI changes
|
||||
- Neeraj: Fix for double enteries for local file
|
||||
- (prtk) Fix widget initial launch on iOS
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
- Video streaming improvements
|
||||
- Added support for custom domain links
|
||||
- Image editor fixes:
|
||||
- Fixed bottom navigation bar color in light theme
|
||||
|
||||
356
mobile/apps/photos/test/services/video_preview_service_test.dart
Normal file
356
mobile/apps/photos/test/services/video_preview_service_test.dart
Normal file
@@ -0,0 +1,356 @@
|
||||
import "package:dio/dio.dart";
|
||||
import "package:flutter_cache_manager/flutter_cache_manager.dart";
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import "package:mockito/annotations.dart";
|
||||
import "package:photos/core/configuration.dart";
|
||||
import "package:photos/db/files_db.dart";
|
||||
import "package:photos/db/upload_locks_db.dart";
|
||||
import 'package:photos/models/file/file.dart';
|
||||
import 'package:photos/models/file/file_type.dart';
|
||||
import 'package:photos/models/metadata/file_magic.dart';
|
||||
import "package:photos/service_locator.dart";
|
||||
import "package:photos/services/file_magic_service.dart";
|
||||
import 'package:photos/services/filedata/model/file_data.dart';
|
||||
import "package:photos/services/isolated_ffmpeg_service.dart";
|
||||
import 'package:photos/services/video_preview_service.dart';
|
||||
|
||||
import "video_preview_service_test.mocks.dart";
|
||||
|
||||
@GenerateMocks([
|
||||
ServiceLocator,
|
||||
Configuration,
|
||||
FilesDB,
|
||||
UploadLocksDB,
|
||||
FileMagicService,
|
||||
IsolatedFfmpegService,
|
||||
Dio,
|
||||
DefaultCacheManager,
|
||||
CacheManager,
|
||||
])
|
||||
void main() {
|
||||
late VideoPreviewService videoPreviewService;
|
||||
late MockServiceLocator mockServiceLocator;
|
||||
late MockConfiguration mockConfiguration;
|
||||
late MockFilesDB mockFilesDB;
|
||||
late MockUploadLocksDB mockUploadLocksDB;
|
||||
late MockFileMagicService mockFileMagicService;
|
||||
late MockIsolatedFfmpegService mockFfmpegService;
|
||||
late MockDefaultCacheManager mockCacheManager;
|
||||
late MockCacheManager mockVideoCacheManager;
|
||||
|
||||
setUp(() {
|
||||
mockServiceLocator = MockServiceLocator();
|
||||
mockConfiguration = MockConfiguration();
|
||||
mockFilesDB = MockFilesDB();
|
||||
mockUploadLocksDB = MockUploadLocksDB();
|
||||
mockFileMagicService = MockFileMagicService();
|
||||
mockFfmpegService = MockIsolatedFfmpegService();
|
||||
mockCacheManager = MockDefaultCacheManager();
|
||||
mockVideoCacheManager = MockCacheManager();
|
||||
|
||||
videoPreviewService = VideoPreviewService(
|
||||
mockConfiguration,
|
||||
mockServiceLocator,
|
||||
mockFilesDB,
|
||||
mockUploadLocksDB,
|
||||
mockFileMagicService,
|
||||
mockFfmpegService,
|
||||
mockCacheManager,
|
||||
mockVideoCacheManager,
|
||||
);
|
||||
});
|
||||
|
||||
group('calcStatus Logic Tests', () {
|
||||
test('should handle mixed processed and unprocessed files', () async {
|
||||
// Arrange
|
||||
final files = [
|
||||
EnteFile()
|
||||
..uploadedFileID = 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
EnteFile()
|
||||
..uploadedFileID = 2
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
EnteFile()
|
||||
..uploadedFileID = 3
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
];
|
||||
|
||||
final previewIds = <int, PreviewInfo>{
|
||||
2: PreviewInfo(
|
||||
objectId: 'obj2',
|
||||
objectSize: 1000,
|
||||
), // Only file 2 is processed
|
||||
};
|
||||
|
||||
final status = await videoPreviewService.calcStatus(files, previewIds);
|
||||
|
||||
// Assert
|
||||
expect(status, closeTo(0.33, 0.01)); // 1/3 ≈ 0.33
|
||||
});
|
||||
|
||||
test('should handle all files processed', () async {
|
||||
// Arrange
|
||||
final files = [
|
||||
EnteFile()
|
||||
..uploadedFileID = 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
EnteFile()
|
||||
..uploadedFileID = 2
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
];
|
||||
|
||||
final previewIds = <int, PreviewInfo>{
|
||||
1: PreviewInfo(objectId: 'obj1', objectSize: 1000),
|
||||
2: PreviewInfo(objectId: 'obj2', objectSize: 1000),
|
||||
};
|
||||
|
||||
final status = await videoPreviewService.calcStatus(files, previewIds);
|
||||
|
||||
// Assert
|
||||
expect(status, equals(1.0)); // 100% processed
|
||||
});
|
||||
|
||||
test('should handle no files processed', () async {
|
||||
// Arrange
|
||||
final files = [
|
||||
EnteFile()
|
||||
..uploadedFileID = 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
EnteFile()
|
||||
..uploadedFileID = 2
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
];
|
||||
|
||||
final previewIds = <int, PreviewInfo>{}; // No files processed
|
||||
|
||||
final status = await videoPreviewService.calcStatus(files, previewIds);
|
||||
|
||||
// Assert
|
||||
expect(status, equals(0.0)); // 0% processed
|
||||
});
|
||||
|
||||
test('should skip files with sv=1 from total count', () async {
|
||||
// Arrange
|
||||
final files = [
|
||||
EnteFile()
|
||||
..uploadedFileID = 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
EnteFile()
|
||||
..uploadedFileID = 2
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(sv: 1), // Should be skipped
|
||||
EnteFile()
|
||||
..uploadedFileID = 3
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
];
|
||||
|
||||
final previewIds = <int, PreviewInfo>{}; // No files processed
|
||||
|
||||
final status = await videoPreviewService.calcStatus(files, previewIds);
|
||||
|
||||
// Assert
|
||||
expect(status, equals(0.0)); // 0% processed
|
||||
});
|
||||
|
||||
test('should handle processed files with sv=1', () async {
|
||||
// Arrange
|
||||
final files = [
|
||||
EnteFile()
|
||||
..uploadedFileID = 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
EnteFile()
|
||||
..uploadedFileID = 2
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata =
|
||||
PubMagicMetadata(sv: 1), // Processed but with sv=1
|
||||
EnteFile()
|
||||
..uploadedFileID = 3
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
];
|
||||
|
||||
final previewIds = <int, PreviewInfo>{
|
||||
2: PreviewInfo(
|
||||
objectId: 'obj2',
|
||||
objectSize: 1000,
|
||||
), // File 2 is processed
|
||||
};
|
||||
|
||||
final status = await videoPreviewService.calcStatus(files, previewIds);
|
||||
|
||||
// Assert
|
||||
// Logic:
|
||||
// file 2 is processed -> goes to processed set (processed = {2})
|
||||
// All files except sv=1 go to total: file 1 -> total, file 2 -> total, file 3 -> total
|
||||
// So: processed = {2}, total = {1,2,3}
|
||||
// netProcessedItems = processed.length / total.length = 1/3 ≈ 0.33
|
||||
expect(status, closeTo(0.33, 0.01));
|
||||
});
|
||||
|
||||
test('should handle empty file list', () async {
|
||||
// Arrange
|
||||
final files = <EnteFile>[];
|
||||
final previewIds = <int, PreviewInfo>{};
|
||||
|
||||
final status = await videoPreviewService.calcStatus(files, previewIds);
|
||||
|
||||
// Assert
|
||||
expect(status, equals(1.0)); // Empty = 100% complete
|
||||
});
|
||||
|
||||
test('should handle complex scenario', () async {
|
||||
// Arrange
|
||||
final files = [
|
||||
EnteFile()
|
||||
..uploadedFileID = 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(), // Regular file
|
||||
EnteFile()
|
||||
..uploadedFileID = 2
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata =
|
||||
PubMagicMetadata(sv: 1), // Skip from total (sv=1)
|
||||
EnteFile()
|
||||
..uploadedFileID = 3
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(), // Regular file
|
||||
EnteFile()
|
||||
..uploadedFileID = 4
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(), // Regular file
|
||||
EnteFile()
|
||||
..uploadedFileID = 5
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata =
|
||||
PubMagicMetadata(sv: 1), // Skip from total (sv=1)
|
||||
];
|
||||
|
||||
final previewIds = <int, PreviewInfo>{
|
||||
1: PreviewInfo(objectId: 'obj1', objectSize: 1000), // File 1 processed
|
||||
3: PreviewInfo(objectId: 'obj3', objectSize: 1000), // File 3 processed
|
||||
5: PreviewInfo(
|
||||
objectId: 'obj5',
|
||||
objectSize: 1000,
|
||||
), // File 5 processed (but has sv=1)
|
||||
};
|
||||
|
||||
final status = await videoPreviewService.calcStatus(files, previewIds);
|
||||
|
||||
// Assert
|
||||
// File 1: processed -> add to processed {1}, then add to total {1}
|
||||
// File 2: not processed, sv=1 -> skip (continue)
|
||||
// File 3: processed -> add to processed {1,3}, then add to total {1,3}
|
||||
// File 4: not processed, sv!=1 -> add to total {1,3,4}
|
||||
// File 5: processed -> add to processed {1,3,5}, then add to total {1,3,4,5}
|
||||
//
|
||||
// Result: processed = {1,3,5}, total = {1,3,4,5}
|
||||
// netProcessedItems = 3/4 = 0.75
|
||||
expect(status, equals(0.75));
|
||||
});
|
||||
|
||||
test('should handle null pubMagicMetadata', () async {
|
||||
// Arrange
|
||||
final files = [
|
||||
EnteFile()
|
||||
..uploadedFileID = 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = null, // null metadata
|
||||
EnteFile()
|
||||
..uploadedFileID = 2
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
];
|
||||
|
||||
final previewIds = <int, PreviewInfo>{
|
||||
1: PreviewInfo(objectId: 'obj1', objectSize: 1000),
|
||||
};
|
||||
|
||||
final status = await videoPreviewService.calcStatus(files, previewIds);
|
||||
|
||||
// Assert
|
||||
expect(status, equals(0.5)); // 1/2 = 0.5
|
||||
});
|
||||
|
||||
test('should handle large numbers correctly', () async {
|
||||
// Arrange - Create 1000 files, 750 processed
|
||||
final files = List.generate(
|
||||
1000,
|
||||
(index) => EnteFile()
|
||||
..uploadedFileID = index + 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
);
|
||||
|
||||
final previewIds = <int, PreviewInfo>{};
|
||||
// Make first 750 files processed
|
||||
for (int i = 1; i <= 750; i++) {
|
||||
previewIds[i] = PreviewInfo(objectId: 'obj$i', objectSize: 1000);
|
||||
}
|
||||
|
||||
final status = await videoPreviewService.calcStatus(files, previewIds);
|
||||
|
||||
// Assert
|
||||
expect(status, equals(0.75)); // 750/1000 = 0.75
|
||||
});
|
||||
|
||||
test('should handle edge case - all files have sv=1', () async {
|
||||
// Arrange
|
||||
final files = [
|
||||
EnteFile()
|
||||
..uploadedFileID = 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(sv: 1),
|
||||
EnteFile()
|
||||
..uploadedFileID = 2
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(sv: 1),
|
||||
];
|
||||
|
||||
final previewIds = <int, PreviewInfo>{
|
||||
1: PreviewInfo(objectId: 'obj1', objectSize: 1000),
|
||||
2: PreviewInfo(objectId: 'obj2', objectSize: 1000),
|
||||
};
|
||||
|
||||
final status = await videoPreviewService.calcStatus(files, previewIds);
|
||||
|
||||
// Assert
|
||||
// All files have sv=1, so total set is empty, netProcessedItems should be 1.0
|
||||
expect(
|
||||
status,
|
||||
equals(1.0),
|
||||
); // Empty total = 100% complete
|
||||
});
|
||||
|
||||
test('should handle percentage calculation precision', () async {
|
||||
// Arrange - Test precise percentage calculations
|
||||
final files = List.generate(
|
||||
7,
|
||||
(index) => EnteFile()
|
||||
..uploadedFileID = index + 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
);
|
||||
|
||||
final previewIds = <int, PreviewInfo>{
|
||||
1: PreviewInfo(objectId: 'obj1', objectSize: 1000),
|
||||
2: PreviewInfo(objectId: 'obj2', objectSize: 1000),
|
||||
};
|
||||
|
||||
final status = await videoPreviewService.calcStatus(files, previewIds);
|
||||
|
||||
// Assert
|
||||
// 2 out of 7 files processed = 2/7 ≈ 0.2857
|
||||
expect(status, closeTo(0.2857, 0.0001));
|
||||
});
|
||||
});
|
||||
}
|
||||
545
mobile/apps/photos/test/services/video_preview_status_test.dart
Normal file
545
mobile/apps/photos/test/services/video_preview_status_test.dart
Normal file
@@ -0,0 +1,545 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:photos/core/configuration.dart';
|
||||
import 'package:photos/db/files_db.dart';
|
||||
import 'package:photos/db/upload_locks_db.dart';
|
||||
import 'package:photos/models/file/file.dart';
|
||||
import 'package:photos/models/file/file_type.dart';
|
||||
import 'package:photos/models/metadata/file_magic.dart';
|
||||
import 'package:photos/service_locator.dart';
|
||||
import 'package:photos/services/file_magic_service.dart';
|
||||
import 'package:photos/services/filedata/model/file_data.dart';
|
||||
import 'package:photos/services/isolated_ffmpeg_service.dart';
|
||||
import 'package:photos/services/video_preview_service.dart';
|
||||
|
||||
import 'video_preview_status_test.mocks.dart';
|
||||
|
||||
@GenerateMocks([
|
||||
ServiceLocator,
|
||||
Configuration,
|
||||
FilesDB,
|
||||
UploadLocksDB,
|
||||
FileMagicService,
|
||||
IsolatedFfmpegService,
|
||||
Dio,
|
||||
DefaultCacheManager,
|
||||
CacheManager,
|
||||
])
|
||||
void main() {
|
||||
late VideoPreviewService videoPreviewService;
|
||||
late MockServiceLocator mockServiceLocator;
|
||||
late MockConfiguration mockConfiguration;
|
||||
late MockFilesDB mockFilesDB;
|
||||
late MockUploadLocksDB mockUploadLocksDB;
|
||||
late MockFileMagicService mockFileMagicService;
|
||||
late MockIsolatedFfmpegService mockFfmpegService;
|
||||
late MockDefaultCacheManager mockCacheManager;
|
||||
late MockCacheManager mockVideoCacheManager;
|
||||
|
||||
setUp(() {
|
||||
mockServiceLocator = MockServiceLocator();
|
||||
mockConfiguration = MockConfiguration();
|
||||
mockFilesDB = MockFilesDB();
|
||||
mockUploadLocksDB = MockUploadLocksDB();
|
||||
mockFileMagicService = MockFileMagicService();
|
||||
mockFfmpegService = MockIsolatedFfmpegService();
|
||||
mockCacheManager = MockDefaultCacheManager();
|
||||
mockVideoCacheManager = MockCacheManager();
|
||||
|
||||
videoPreviewService = VideoPreviewService(
|
||||
mockConfiguration,
|
||||
mockServiceLocator,
|
||||
mockFilesDB,
|
||||
mockUploadLocksDB,
|
||||
mockFileMagicService,
|
||||
mockFfmpegService,
|
||||
mockCacheManager,
|
||||
mockVideoCacheManager,
|
||||
);
|
||||
});
|
||||
|
||||
group('VideoPreviewService Queue Management', () {
|
||||
test('should add file to manual queue successfully', () async {
|
||||
// Arrange
|
||||
final file = EnteFile()
|
||||
..uploadedFileID = 123
|
||||
..title = 'test_video.mp4'
|
||||
..collectionID = 1;
|
||||
|
||||
when(mockUploadLocksDB.isInStreamQueue(123))
|
||||
.thenAnswer((_) async => false);
|
||||
when(mockUploadLocksDB.addToStreamQueue(123, any))
|
||||
.thenAnswer((_) async => {});
|
||||
|
||||
final result = await videoPreviewService.addToManualQueue(file, 'create');
|
||||
|
||||
// Assert
|
||||
expect(result, isTrue);
|
||||
verify(mockUploadLocksDB.addToStreamQueue(123, 'create')).called(1);
|
||||
});
|
||||
|
||||
test('should not add file already in queue', () async {
|
||||
// Arrange
|
||||
final file = EnteFile()
|
||||
..uploadedFileID = 123
|
||||
..title = 'test_video.mp4';
|
||||
|
||||
when(mockUploadLocksDB.isInStreamQueue(123))
|
||||
.thenAnswer((_) async => true);
|
||||
|
||||
final result = await videoPreviewService.addToManualQueue(file, 'create');
|
||||
|
||||
// Assert
|
||||
expect(result, isFalse);
|
||||
verifyNever(mockUploadLocksDB.addToStreamQueue(any, any));
|
||||
});
|
||||
|
||||
test('should return false for file without uploadedFileID', () async {
|
||||
// Arrange
|
||||
final file = EnteFile()
|
||||
..title = 'test_video.mp4'
|
||||
..uploadedFileID = null;
|
||||
|
||||
final result = await videoPreviewService.addToManualQueue(file, 'create');
|
||||
|
||||
// Assert
|
||||
expect(result, isFalse);
|
||||
verifyZeroInteractions(mockUploadLocksDB);
|
||||
});
|
||||
|
||||
test('should clear queue properly', () {
|
||||
// Arrange
|
||||
videoPreviewService.fileQueue[123] = EnteFile()..uploadedFileID = 123;
|
||||
videoPreviewService.fileQueue[456] = EnteFile()..uploadedFileID = 456;
|
||||
|
||||
videoPreviewService.clearQueue();
|
||||
|
||||
// Assert
|
||||
expect(videoPreviewService.fileQueue.isEmpty, isTrue);
|
||||
});
|
||||
|
||||
test('should identify currently processing file', () {
|
||||
// Arrange
|
||||
videoPreviewService.uploadingFileId = 123;
|
||||
|
||||
expect(videoPreviewService.isCurrentlyProcessing(123), isTrue);
|
||||
expect(videoPreviewService.isCurrentlyProcessing(456), isFalse);
|
||||
expect(videoPreviewService.isCurrentlyProcessing(null), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('VideoPreviewService Status Calculation Edge Cases', () {
|
||||
test('should handle video files only (no mixed types in calcStatus)',
|
||||
() async {
|
||||
// Note: calcStatus expects to receive only video files from the DB query
|
||||
// Arrange
|
||||
final files = [
|
||||
EnteFile()
|
||||
..uploadedFileID = 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
EnteFile()
|
||||
..uploadedFileID = 3
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
];
|
||||
|
||||
final previewIds = <int, PreviewInfo>{
|
||||
1: PreviewInfo(objectId: 'obj1', objectSize: 1000),
|
||||
};
|
||||
|
||||
final status = await videoPreviewService.calcStatus(files, previewIds);
|
||||
|
||||
// Assert
|
||||
// Logic: file 1 -> processed, add to both processed {1} and total {1}
|
||||
// file 3 -> not processed, add to total {1,3}
|
||||
// Status = 1/2 = 0.5
|
||||
expect(status, equals(0.5));
|
||||
});
|
||||
|
||||
test('should handle files with zero uploadedFileID', () async {
|
||||
// Arrange
|
||||
final files = [
|
||||
EnteFile()
|
||||
..uploadedFileID = 0
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
EnteFile()
|
||||
..uploadedFileID = 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
];
|
||||
|
||||
final previewIds = <int, PreviewInfo>{
|
||||
0: PreviewInfo(objectId: 'obj0', objectSize: 1000),
|
||||
};
|
||||
|
||||
final status = await videoPreviewService.calcStatus(files, previewIds);
|
||||
|
||||
// Assert
|
||||
expect(status, equals(0.5)); // 1/2 = 0.5
|
||||
});
|
||||
|
||||
test('should handle very large preview counts', () async {
|
||||
// Arrange - Create 10,000 files scenario
|
||||
final files = List.generate(
|
||||
10000,
|
||||
(index) => EnteFile()
|
||||
..uploadedFileID = index + 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
);
|
||||
|
||||
final previewIds = <int, PreviewInfo>{};
|
||||
// Process every 3rd file
|
||||
for (int i = 3; i <= 10000; i += 3) {
|
||||
previewIds[i] = PreviewInfo(objectId: 'obj$i', objectSize: 1000);
|
||||
}
|
||||
|
||||
final status = await videoPreviewService.calcStatus(files, previewIds);
|
||||
|
||||
// Assert
|
||||
// Every 3rd file is processed: 3333 files out of 10000
|
||||
expect(status, closeTo(0.3333, 0.0001));
|
||||
});
|
||||
|
||||
test('should handle inconsistent preview data', () async {
|
||||
// Arrange
|
||||
final files = [
|
||||
EnteFile()
|
||||
..uploadedFileID = 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
];
|
||||
|
||||
final previewIds = <int, PreviewInfo>{
|
||||
999: PreviewInfo(
|
||||
objectId: 'obj999',
|
||||
objectSize: 1000,
|
||||
), // Non-existent file
|
||||
};
|
||||
|
||||
final status = await videoPreviewService.calcStatus(files, previewIds);
|
||||
|
||||
// Assert
|
||||
expect(status, equals(0.0)); // No matching processed files
|
||||
});
|
||||
});
|
||||
|
||||
group('VideoPreviewService File Filtering', () {
|
||||
test('should handle files with different sv values', () async {
|
||||
// Arrange
|
||||
final files = [
|
||||
EnteFile()
|
||||
..uploadedFileID = 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(sv: 0), // sv=0 (included)
|
||||
EnteFile()
|
||||
..uploadedFileID = 2
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(sv: 1), // sv=1 (skipped)
|
||||
EnteFile()
|
||||
..uploadedFileID = 3
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(sv: 2), // sv=2 (included)
|
||||
EnteFile()
|
||||
..uploadedFileID = 4
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(), // sv=null (included)
|
||||
];
|
||||
|
||||
final previewIds = <int, PreviewInfo>{
|
||||
1: PreviewInfo(objectId: 'obj1', objectSize: 1000),
|
||||
3: PreviewInfo(objectId: 'obj3', objectSize: 1000),
|
||||
};
|
||||
|
||||
final status = await videoPreviewService.calcStatus(files, previewIds);
|
||||
|
||||
// Assert
|
||||
// Files 1, 3, 4 are included (sv != 1), file 2 is skipped
|
||||
// Files 1, 3 are processed out of 3 total eligible files
|
||||
// Status = 2/3 ≈ 0.6667
|
||||
expect(status, closeTo(0.6667, 0.0001));
|
||||
});
|
||||
|
||||
test('should handle boundary conditions for processing', () async {
|
||||
// Arrange - Test with exactly one file
|
||||
final files = [
|
||||
EnteFile()
|
||||
..uploadedFileID = 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
];
|
||||
|
||||
final previewIds = <int, PreviewInfo>{
|
||||
1: PreviewInfo(objectId: 'obj1', objectSize: 1000),
|
||||
};
|
||||
|
||||
final status = await videoPreviewService.calcStatus(files, previewIds);
|
||||
|
||||
// Assert
|
||||
expect(status, equals(1.0)); // 1/1 = 100%
|
||||
});
|
||||
});
|
||||
|
||||
group('VideoPreviewService Concurrency Tests', () {
|
||||
test('should handle concurrent status calculations', () async {
|
||||
// Arrange
|
||||
final files = List.generate(
|
||||
100,
|
||||
(index) => EnteFile()
|
||||
..uploadedFileID = index + 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
);
|
||||
|
||||
final previewIds = <int, PreviewInfo>{};
|
||||
for (int i = 1; i <= 50; i++) {
|
||||
previewIds[i] = PreviewInfo(objectId: 'obj$i', objectSize: 1000);
|
||||
}
|
||||
|
||||
final futures = List.generate(
|
||||
5,
|
||||
(_) => videoPreviewService.calcStatus(files, previewIds),
|
||||
);
|
||||
final results = await Future.wait(futures);
|
||||
|
||||
// Assert - All results should be consistent
|
||||
for (final result in results) {
|
||||
expect(result, equals(0.5)); // 50/100 = 0.5
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('VideoPreviewService Data Integrity', () {
|
||||
test('should handle corrupted preview data gracefully', () async {
|
||||
// Arrange
|
||||
final files = [
|
||||
EnteFile()
|
||||
..uploadedFileID = 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
EnteFile()
|
||||
..uploadedFileID = 2
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
];
|
||||
|
||||
// Simulate corrupted preview data with negative IDs
|
||||
final previewIds = <int, PreviewInfo>{
|
||||
-1: PreviewInfo(objectId: 'corrupt', objectSize: -100),
|
||||
1: PreviewInfo(objectId: 'obj1', objectSize: 1000),
|
||||
};
|
||||
|
||||
final status = await videoPreviewService.calcStatus(files, previewIds);
|
||||
|
||||
// Assert
|
||||
expect(status, equals(0.5)); // Only file 1 matches and is processed
|
||||
});
|
||||
|
||||
test('should maintain precision with floating point calculations',
|
||||
() async {
|
||||
// Arrange - Test edge case numbers that might cause floating point issues
|
||||
final files = List.generate(
|
||||
3,
|
||||
(index) => EnteFile()
|
||||
..uploadedFileID = index + 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
);
|
||||
|
||||
final previewIds = <int, PreviewInfo>{
|
||||
1: PreviewInfo(objectId: 'obj1', objectSize: 1000),
|
||||
};
|
||||
|
||||
final status = await videoPreviewService.calcStatus(files, previewIds);
|
||||
|
||||
// Assert
|
||||
// 1/3 should be exactly represented or very close
|
||||
expect(status, closeTo(1.0 / 3.0, 0.0000001));
|
||||
});
|
||||
});
|
||||
|
||||
group('VideoPreviewService Complex Scenarios', () {
|
||||
test('should handle real-world mixed scenario', () async {
|
||||
// Arrange - Simulate real photo library scenario (only video files)
|
||||
final files = [
|
||||
// Regular videos
|
||||
...List.generate(
|
||||
50,
|
||||
(index) => EnteFile()
|
||||
..uploadedFileID = index + 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
),
|
||||
|
||||
// Videos with sv=1 (should be skipped)
|
||||
...List.generate(
|
||||
10,
|
||||
(index) => EnteFile()
|
||||
..uploadedFileID = index + 51
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(sv: 1),
|
||||
),
|
||||
|
||||
// Videos with null metadata
|
||||
...List.generate(
|
||||
5,
|
||||
(index) => EnteFile()
|
||||
..uploadedFileID = index + 61
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = null,
|
||||
),
|
||||
];
|
||||
|
||||
// Process 30 regular videos and 2 null-metadata videos
|
||||
final previewIds = <int, PreviewInfo>{};
|
||||
for (int i = 1; i <= 30; i++) {
|
||||
previewIds[i] = PreviewInfo(objectId: 'obj$i', objectSize: 1000);
|
||||
}
|
||||
previewIds[62] = PreviewInfo(objectId: 'obj62', objectSize: 1000);
|
||||
previewIds[64] = PreviewInfo(objectId: 'obj64', objectSize: 1000);
|
||||
|
||||
final status = await videoPreviewService.calcStatus(files, previewIds);
|
||||
|
||||
// Assert
|
||||
// Total eligible: 50 regular + 5 null-metadata = 55 videos (sv=1 files are skipped)
|
||||
// Processed: 30 regular + 2 null-metadata = 32 videos
|
||||
// Logic: processed files go to both processed and total sets
|
||||
// unprocessed files (except sv=1) go to total set only
|
||||
// So: processed = {1..30, 62, 64}, total = {1..50, 61..65}
|
||||
// Status = 32/55 ≈ 0.5818
|
||||
expect(status, closeTo(32.0 / 55.0, 0.0001));
|
||||
});
|
||||
|
||||
test('should handle progressive processing simulation', () async {
|
||||
// Arrange
|
||||
final files = List.generate(
|
||||
10,
|
||||
(index) => EnteFile()
|
||||
..uploadedFileID = index + 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
);
|
||||
|
||||
for (int processed = 0; processed <= 10; processed++) {
|
||||
final previewIds = <int, PreviewInfo>{};
|
||||
for (int i = 1; i <= processed; i++) {
|
||||
previewIds[i] = PreviewInfo(objectId: 'obj$i', objectSize: 1000);
|
||||
}
|
||||
|
||||
final status = await videoPreviewService.calcStatus(files, previewIds);
|
||||
final expectedStatus = processed / 10.0;
|
||||
|
||||
expect(
|
||||
status,
|
||||
equals(expectedStatus),
|
||||
reason: 'Failed at $processed processed files',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('VideoPreviewService Performance Edge Cases', () {
|
||||
test('should handle empty preview IDs efficiently', () async {
|
||||
// Arrange
|
||||
final files = List.generate(
|
||||
1000,
|
||||
(index) => EnteFile()
|
||||
..uploadedFileID = index + 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
);
|
||||
|
||||
final emptyPreviewIds = <int, PreviewInfo>{};
|
||||
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final status =
|
||||
await videoPreviewService.calcStatus(files, emptyPreviewIds);
|
||||
stopwatch.stop();
|
||||
|
||||
// Assert
|
||||
expect(status, equals(0.0));
|
||||
// Should complete quickly even with 1000 files
|
||||
expect(stopwatch.elapsedMilliseconds, lessThan(100));
|
||||
});
|
||||
|
||||
test('should handle all-processed scenario efficiently', () async {
|
||||
// Arrange
|
||||
final files = List.generate(
|
||||
1000,
|
||||
(index) => EnteFile()
|
||||
..uploadedFileID = index + 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
);
|
||||
|
||||
final allProcessedIds = <int, PreviewInfo>{};
|
||||
for (int i = 1; i <= 1000; i++) {
|
||||
allProcessedIds[i] = PreviewInfo(objectId: 'obj$i', objectSize: 1000);
|
||||
}
|
||||
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final status =
|
||||
await videoPreviewService.calcStatus(files, allProcessedIds);
|
||||
stopwatch.stop();
|
||||
|
||||
// Assert
|
||||
expect(status, equals(1.0));
|
||||
// Should complete quickly even with all files processed
|
||||
expect(stopwatch.elapsedMilliseconds, lessThan(100));
|
||||
});
|
||||
});
|
||||
|
||||
group('VideoPreviewService Status Boundary Conditions', () {
|
||||
test('should clamp status between 0 and 1', () async {
|
||||
// This test verifies that the clamp function works as expected
|
||||
// even though we don't expect values outside [0,1] in normal cases
|
||||
|
||||
// Arrange - Normal case that should result in valid percentage
|
||||
final files = [
|
||||
EnteFile()
|
||||
..uploadedFileID = 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
];
|
||||
|
||||
final previewIds = <int, PreviewInfo>{
|
||||
1: PreviewInfo(objectId: 'obj1', objectSize: 1000),
|
||||
};
|
||||
|
||||
final status = await videoPreviewService.calcStatus(files, previewIds);
|
||||
|
||||
// Assert
|
||||
expect(status, greaterThanOrEqualTo(0.0));
|
||||
expect(status, lessThanOrEqualTo(1.0));
|
||||
});
|
||||
|
||||
test('should handle duplicate uploadedFileIDs consistently', () async {
|
||||
// Arrange - This shouldn't happen in practice, but test robustness
|
||||
final files = [
|
||||
EnteFile()
|
||||
..uploadedFileID = 1
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
EnteFile()
|
||||
..uploadedFileID = 1 // Duplicate ID
|
||||
..fileType = FileType.video
|
||||
..pubMagicMetadata = PubMagicMetadata(),
|
||||
];
|
||||
|
||||
final previewIds = <int, PreviewInfo>{
|
||||
1: PreviewInfo(objectId: 'obj1', objectSize: 1000),
|
||||
};
|
||||
|
||||
final status = await videoPreviewService.calcStatus(files, previewIds);
|
||||
|
||||
// Assert
|
||||
// Sets should handle duplicates naturally
|
||||
expect(status, equals(1.0)); // 1/1 = 100%
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -4,11 +4,11 @@ import 'dart:convert';
|
||||
import 'dart:io' as io;
|
||||
|
||||
import 'package:bip39/bip39.dart' as bip39;
|
||||
import 'package:ente_configuration/constants.dart';
|
||||
import 'package:ente_base/models/database.dart';
|
||||
import 'package:ente_base/models/key_attributes.dart';
|
||||
import 'package:ente_base/models/key_gen_result.dart';
|
||||
import 'package:ente_base/models/private_key_attributes.dart';
|
||||
import 'package:ente_configuration/constants.dart';
|
||||
import 'package:ente_crypto_dart/ente_crypto_dart.dart';
|
||||
import 'package:ente_events/event_bus.dart';
|
||||
import 'package:ente_events/models/endpoint_updated_event.dart';
|
||||
@@ -35,6 +35,7 @@ class BaseConfiguration {
|
||||
static const userIDKey = "user_id";
|
||||
static const endPointKey = "endpoint";
|
||||
static const lastTempFolderClearTimeKey = "last_temp_folder_clear_time";
|
||||
static const offlineAuthSecretKey = "offline_auth_secret_key";
|
||||
|
||||
final kTempFolderDeletionTimeBuffer = const Duration(days: 1).inMicroseconds;
|
||||
|
||||
@@ -52,6 +53,11 @@ class BaseConfiguration {
|
||||
|
||||
String? _volatilePassword;
|
||||
|
||||
// Keys that should not be deleted during logout
|
||||
// These keys are necessary for functionality that needs to work even when users
|
||||
// aren't signed in, such as using Auth without backup
|
||||
List<String> preservedKeys = [offlineAuthSecretKey];
|
||||
|
||||
Future<void> init(List<EnteBaseDatabase> dbs) async {
|
||||
_databases = dbs;
|
||||
_documentsDirectory = (await getApplicationDocumentsDirectory()).path;
|
||||
@@ -62,13 +68,13 @@ class BaseConfiguration {
|
||||
accessibility: KeychainAccessibility.first_unlock_this_device,
|
||||
),
|
||||
);
|
||||
_setupKeys();
|
||||
_setupFolders();
|
||||
await _setupKeys();
|
||||
await _setupFolders();
|
||||
}
|
||||
|
||||
Future<void> logout({bool autoLogout = false}) async {
|
||||
await _preferences.clear();
|
||||
_secureStorage.deleteAll();
|
||||
await resetSecureStorage();
|
||||
for (final db in _databases) {
|
||||
await db.clearTable();
|
||||
}
|
||||
@@ -78,6 +84,16 @@ class BaseConfiguration {
|
||||
Bus.instance.fire(SignedOutEvent());
|
||||
}
|
||||
|
||||
Future<void> resetSecureStorage() async {
|
||||
// Delete all keys except preserved ones
|
||||
final allKeys = await _secureStorage.readAll();
|
||||
for (final key in allKeys.keys) {
|
||||
if (!preservedKeys.contains(key)) {
|
||||
await _secureStorage.delete(key: key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<KeyGenResult> generateKey(String password) async {
|
||||
// Create a master key
|
||||
final masterKey = CryptoUtil.generateKey();
|
||||
@@ -217,7 +233,7 @@ class BaseConfiguration {
|
||||
if (split.length != mnemonicKeyWordCount) {
|
||||
String wordThatIsFollowedByEmptySpaceInSplit = '';
|
||||
for (int i = 0; i < split.length; i++) {
|
||||
String word = split[i];
|
||||
final String word = split[i];
|
||||
if (word.isEmpty) {
|
||||
wordThatIsFollowedByEmptySpaceInSplit =
|
||||
'\n\nExtra space after word at position $i';
|
||||
@@ -382,7 +398,7 @@ class BaseConfiguration {
|
||||
Future<void> _setupKeys() async {
|
||||
try {
|
||||
if (!_preferences.containsKey(tokenKey)) {
|
||||
await _secureStorage.deleteAll();
|
||||
await resetSecureStorage();
|
||||
return;
|
||||
}
|
||||
_key = await _secureStorage.read(key: keyKey);
|
||||
|
||||
@@ -976,6 +976,8 @@ const localeName = (locale: SupportedLocale) => {
|
||||
return "اَلْعَرَبِيَّةُ";
|
||||
case "tr-TR":
|
||||
return "Türkçe";
|
||||
case "cs-CZ":
|
||||
return "čeština";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export const supportedLocales = [
|
||||
"ja-JP" /* Japanese */,
|
||||
"ar-SA" /* Arabic */,
|
||||
"tr-TR" /* Turkish */,
|
||||
"cs-CZ" /* Czech */,
|
||||
] as const;
|
||||
|
||||
/** The type of {@link supportedLocales}. */
|
||||
@@ -201,6 +202,8 @@ const closestSupportedLocale = (
|
||||
return "ar-SA";
|
||||
} else if (ls.startsWith("tr")) {
|
||||
return "tr-TR";
|
||||
} else if (ls.startsWith("cs")) {
|
||||
return "cs-CZ";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"intro_slide_1_title": "Soukromé zálohy<br/>pro vaše vzpomínky",
|
||||
"intro_slide_1": "End-to-end šifrováno ve výchozím nastavení",
|
||||
"intro_slide_2_title": "Bezpečně skladovaný<br/>v úkryt",
|
||||
"intro_slide_1": "Koncově šifrováno ve výchozím nastavení",
|
||||
"intro_slide_2_title": "Bezpečně uloženo<br/>v protiatomovém krytu",
|
||||
"intro_slide_2": "Navržen pro prežití",
|
||||
"intro_slide_3_title": "Dostupné<br/>všude",
|
||||
"intro_slide_3": "Android, iOS, Web, počítač",
|
||||
"intro_slide_3": "Android, iOS, Web, Počítač",
|
||||
"login": "Přihlášení",
|
||||
"sign_up": "Registrace",
|
||||
"new_to_ente": "Nový uživatel Ente",
|
||||
@@ -32,17 +32,17 @@
|
||||
"set_password": "Nastavit heslo",
|
||||
"sign_in": "Přihlásit se",
|
||||
"incorrect_password": "Nesprávné heslo",
|
||||
"incorrect_password_or_no_account": "",
|
||||
"incorrect_password_or_no_account": "Nesprávné heslo nebo e-mail není zaregistrován",
|
||||
"pick_password_hint": "Zadejte heslo, kterým můžeme zašifrovat Vaše data",
|
||||
"pick_password_caution": "Vaše heslo neukládáme, takže pokud ho zapomenete, <strong>nebudeme moci pomoci </strong>obnovit vaše data bez obnovovacího klíče.",
|
||||
"pick_password_caution": "Vaše heslo neukládáme, takže pokud ho zapomenete, <strong>nebudeme vám moci pomoci </strong>obnovit vaše data bez obnovovacího klíče.",
|
||||
"key_generation_in_progress": "Generování šifrovacích klíčů...",
|
||||
"confirm_password": "Potvrďte heslo",
|
||||
"referral_source_hint": "Jak jste se dozvěděli o Ente? (volitelné)",
|
||||
"referral_source_info": "Ne sledujeme instalace aplikace. Pomůže nám, když nám sdělíte, kde jste nás našli!",
|
||||
"referral_source_info": "Nesledujeme instalace aplikace. Pomůže nám, když nám sdělíte, kde jste nás našli!",
|
||||
"password_mismatch_error": "Hesla se neshodují",
|
||||
"show_or_hide_password": "",
|
||||
"show_or_hide_password": "Zobrazit nebo skrýt heslo",
|
||||
"welcome_to_ente_title": "Vítejte v <a/>",
|
||||
"welcome_to_ente_subtitle": "End to end šifrované úložiště fotek a sdílení",
|
||||
"welcome_to_ente_subtitle": "Ukládání a sdílení fotografií s koncovým šifrováním",
|
||||
"new_album": "Nové album",
|
||||
"create_albums": "Vytvořit alba",
|
||||
"album_name": "Název alba",
|
||||
@@ -55,7 +55,7 @@
|
||||
"add_photos": "Přidat fotky",
|
||||
"add_more_photos": "Přidat další fotky",
|
||||
"add_photos_count_one": "Přidat 1 položku",
|
||||
"add_photos_count": "Přidat {{count, number}} předměty",
|
||||
"add_photos_count": "Přidat {{count, number}} položek",
|
||||
"select_photos": "Vybrat fotky",
|
||||
"file_upload": "Nahrát soubor",
|
||||
"preparing": "Příprava",
|
||||
@@ -64,9 +64,9 @@
|
||||
"upload_cancelling": "Ruší se zbývající nahrávání",
|
||||
"upload_done": "{{count, number}} nahráno",
|
||||
"upload_skipped": "{{count, number}} přeskočeno",
|
||||
"initial_load_delay_warning": "První načitení může chvíli trvat",
|
||||
"no_account": "Nemáte účet",
|
||||
"existing_account": "Již máte účet",
|
||||
"initial_load_delay_warning": "První načítání může chvíli trvat",
|
||||
"no_account": "Nemám účet",
|
||||
"existing_account": "Již mám účet",
|
||||
"create": "Vytvořit",
|
||||
"files_count": "{{count, number}} souborů",
|
||||
"download": "Stáhnout",
|
||||
@@ -78,10 +78,10 @@
|
||||
"more": "Víc",
|
||||
"mouse_scroll": "Posunutí myší",
|
||||
"pan": "Posouvat",
|
||||
"pinch": "",
|
||||
"pinch": "Přiblížit",
|
||||
"drag": "Přetáhnout",
|
||||
"tap_inside_image": "Klepněte na obrázek",
|
||||
"tap_outside_image": "Klepněte mimo obrázku",
|
||||
"tap_outside_image": "Klepněte mimo obrázek",
|
||||
"shortcuts": "Zkratky",
|
||||
"show_shortcuts": "Zobrazit zkratky",
|
||||
"zoom_preset": "Nastaveni zoomu",
|
||||
@@ -116,17 +116,17 @@
|
||||
"trash_file_title": "Smazat soubor?",
|
||||
"delete_files_title": "Odstranit okamžitě?",
|
||||
"delete_files_message": "Vybrané soubory budou trvale odstraněny z vašeho účtu Ente.",
|
||||
"selected_count": "{{selected, number}} vybráno",
|
||||
"selected_and_yours_count": "{{selected, number}} vybral {{yours, number}} tvůj",
|
||||
"selected_count": "Vybráno: {{selected, number}}",
|
||||
"selected_and_yours_count": "{{selected, number}} vybráno, {{yours, number}} tvých",
|
||||
"delete": "Smazat",
|
||||
"favorite": "Oblíbené",
|
||||
"convert": "Konverze",
|
||||
"convert": "Převést",
|
||||
"multi_folder_upload": "Bylo nalezeno více složek",
|
||||
"upload_to_choice": "Chcete je nahrát do",
|
||||
"upload_to_single_album": "Jednoho alba",
|
||||
"upload_to_album_per_folder": "Oddělená alba",
|
||||
"upload_to_album_per_folder": "Oddělených alb",
|
||||
"session_expired": "Relace vypršela",
|
||||
"session_expired_message": "Vaše relace vypršela. Přihlaste se, prosím, znovu.",
|
||||
"session_expired_message": "Vaše relace vypršela, prosím přihlaste se znovu, abyste mohli pokračovat",
|
||||
"password_generation_failed": "Váš prohlížeč nemohl vygenerovat silný klíč, který splňuje normy Ente pro šifrování, zkuste to prosím pomocí mobilní aplikace nebo jiného prohlížeče",
|
||||
"change_password": "Změnit heslo",
|
||||
"password_changed_elsewhere": "Heslo bylo změněno jinde",
|
||||
@@ -137,7 +137,7 @@
|
||||
"do_this_later": "Udělat později",
|
||||
"save_key": "Uložit klíč",
|
||||
"recovery_key_description": "Tento klíč je jedinou cestou pro obnovení Vašich dat, pokud zapomenete heslo.",
|
||||
"key_not_stored_note": "Tento místný klíč neuchováváme, uschovejte si ho, prosím, na bezpečném místě",
|
||||
"key_not_stored_note": "Tento klíč neuchováváme, uschovejte si ho, prosím, na bezpečném místě",
|
||||
"recovery_key_generation_failed": "Obnovovací kód nelze vygenerovat, zkuste to prosím znovu",
|
||||
"forgot_password": "Zapomenuté heslo",
|
||||
"recover_account": "Obnovit účet",
|
||||
@@ -145,11 +145,11 @@
|
||||
"no_recovery_key_title": "Nemáte obnovovací klíč?",
|
||||
"incorrect_recovery_key": "Nesprávný obnovovací klíč",
|
||||
"sorry": "Omlouváme se",
|
||||
"no_recovery_key_message": "Vzhledem k povaze našeho end-to-end šifrovacího protokolu nelze vaše data dešifrovat bez vašeho hesla nebo obnovovacího klíče",
|
||||
"no_recovery_key_message": "Vzhledem k povaze našeho koncového šifrovacího protokolu nelze vaše data dešifrovat bez vašeho hesla nebo obnovovacího klíče",
|
||||
"no_two_factor_recovery_key_message": "Prosím zašlete e-mail na <a>{{emailID}}</a> z vaší registrované e-mailové adresy",
|
||||
"contact_support": "Kontaktovat podporu",
|
||||
"help": "Pomoc",
|
||||
"ente_help": "Ente pomoc",
|
||||
"ente_help": "Ente Nápověda",
|
||||
"blog": "Blog",
|
||||
"request_feature": "Žádost o funkci",
|
||||
"support": "Podpora",
|
||||
@@ -157,33 +157,33 @@
|
||||
"logout": "Odhlásit se",
|
||||
"logout_message": "Opravdu se chcete odhlásit?",
|
||||
"delete_account": "Odstranit účet",
|
||||
"delete_account_manually_message": "<p>Pošlete prosím e-mail na <a>{{emailID}}</a> z vaší registrované e-mailové adresy.</p><p>Vaše požadavka bude zpracována do 72 hodin.</p>",
|
||||
"delete_account_manually_message": "<p>Pošlete prosím e-mail na <a>{{emailID}}</a> z vaší registrované e-mailové adresy.</p><p>Váš požadavek bude zpracován do 72 hodin.</p>",
|
||||
"change_email": "Změnit e-mail",
|
||||
"ok": "OK",
|
||||
"success": "Úspěch",
|
||||
"error": "Chyba",
|
||||
"note": "",
|
||||
"note": "Poznámka",
|
||||
"offline_message": "Jste offline, soubory se zobrazují z mezipaměti",
|
||||
"install": "Instalovat",
|
||||
"install_mobile_app": "Nainstalujte naši aplikaci <a>Android</a> nebo <b>iOS</b> pro automatické zálohování všech vašich fotografií",
|
||||
"download_app": "Stáhnout Pc aplikaci",
|
||||
"install_mobile_app": "Nainstalujte naši <a>Android</a> nebo <b>iOS</b> aplikaci pro automatické zálohování všech vašich fotografií",
|
||||
"download_app": "Stáhnout aplikaci na počítač",
|
||||
"download_app_message": "Omlouváme se, tato operace je v současné době podporována pouze v naší desktopové aplikaci",
|
||||
"subscription": "Předplatné",
|
||||
"manage_payment_method": "Spravovat způsob platby",
|
||||
"manage_family": "Správa rodiny",
|
||||
"family_plan": "Plán rodiny",
|
||||
"leave_family_plan": "Opustit rodinný plán",
|
||||
"leave": "Odejít",
|
||||
"leave_family_plan_confirm": "Jste si jisti, že chcete opustit rodinný plán?",
|
||||
"choose_plan": "Vyberte si svůj plán",
|
||||
"family_plan": "Rodinný tarif",
|
||||
"leave_family_plan": "Opustit rodinný tarif",
|
||||
"leave": "Pustit",
|
||||
"leave_family_plan_confirm": "Jste si jisti, že chcete opustit rodinný tarif?",
|
||||
"choose_plan": "Vyberte si svůj tarif",
|
||||
"manage_plan": "Spravovat své předplatné",
|
||||
"current_usage": "Aktuální využití je <strong>{{usage}}</strong>",
|
||||
"two_months_free": "Získejte 2 měsíce zdarma při ročních plánech",
|
||||
"free_plan_option": "Pokračovat s bezplatným plánem",
|
||||
"two_months_free": "Získejte 2 měsíce zdarma při ročních tarifech",
|
||||
"free_plan_option": "Pokračovat s bezplatným tarifem",
|
||||
"free_plan_description": "{{storage}} zdarma navždy",
|
||||
"active": "Aktivní",
|
||||
"subscription_info_free": "Jste na bezplatném plánu",
|
||||
"subscription_info_family": "Jste na plánu rodiny řízeném",
|
||||
"subscription_info_free": "Jste na bezplatném tarifu",
|
||||
"subscription_info_family": "Jste v rodinném tarifu spravovaném",
|
||||
"subscription_info_expired": "Vaše předplatné vypršelo, prosím <a>obnovte</a>",
|
||||
"subscription_info_renewal_cancelled": "Tvé předplatné bude zrušeno dne {{date, date}}",
|
||||
"subscription_info_storage_quota_exceeded": "Překročili jste Vaši kvótu na úložiště, prosím <a>upgradujte</a>",
|
||||
@@ -196,33 +196,33 @@
|
||||
"subscription_purchase_cancelled": "Váš nákup byl zrušen, zkuste to prosím znovu, pokud chcete předplatné",
|
||||
"subscription_purchase_failed": "Nákup předplatného se nezdařil, zkuste to znovu",
|
||||
"subscription_verification_error": "Ověření předplatného selhalo",
|
||||
"update_payment_method_message": "Omlouváme se, platba se nezdařila, když jsme se pokusili účtovat Vaši kartu, prosím aktualizujte Vaši platební metodu a zkuste to znovu",
|
||||
"update_payment_method_message": "Omlouváme se, platba se nezdařila, když jsme se pokusili strhnout částku z vaší karty. Aktualizujte prosím způsob platby a zkuste to znovu",
|
||||
"payment_method_authentication_failed": "Nelze ověřit způsob platby. Zvolte prosím jiný způsob platby a zkuste to znovu",
|
||||
"update_payment_method": "Aktualizovat způsob platby",
|
||||
"monthly": "Měsíčně",
|
||||
"yearly": "Ročně",
|
||||
"month_short": "měs",
|
||||
"year": "rok",
|
||||
"update_subscription": "Změna plánu",
|
||||
"update_subscription_title": "Potvrdit změnu plánu",
|
||||
"update_subscription_message": "Jste si jisti, že chcete změnit svůj plán?",
|
||||
"update_subscription": "Změnit tarif",
|
||||
"update_subscription_title": "Potvrdit změnu tarifu",
|
||||
"update_subscription_message": "Jste si jisti, že chcete změnit svůj tarif?",
|
||||
"cancel_subscription": "Zrušit předplatné",
|
||||
"cancel_subscription_message": "<p>Všechna vaše data budou smazána z našich serverů na konci tohoto fakturačního období.</p><p>Jste si jisti, že chcete zrušit své předplatné?</p>",
|
||||
"cancel_subscription_with_addon_message": "<p>Opravdu chcete zrušit své předplatné?</p>",
|
||||
"subscription_cancel_success": "Předplatné úspěšně zrušeno",
|
||||
"reactivate_subscription": "Znovu aktivovat předplatné",
|
||||
"reactivate_subscription_message": "Po reaktivaci budete účtován {{date, date}}",
|
||||
"reactivate_subscription_message": "Po opětovné aktivaci Vám bude účtována částka dne {date, date}",
|
||||
"subscription_activate_success": "Předplatné úspěšně aktivováno ",
|
||||
"thank_you": "Děkujeme",
|
||||
"cancel_subscription_on_mobile": "Zrušit předplatné mobilu",
|
||||
"cancel_subscription_on_mobile": "Zrušit mobilní předplatné",
|
||||
"cancel_subscription_on_mobile_message": "Zrušte prosím předplatné z mobilní aplikace pro aktivaci předplatného zde",
|
||||
"mail_to_manage_subscription": "Kontaktujte nás prosím na <a>{{emailID}}</a> pro správu předplatného",
|
||||
"rename": "Přejmenovat",
|
||||
"rename_file": "Přejmenovat soubor",
|
||||
"rename_album": "Přejmenovat album",
|
||||
"delete_album": "Vymazat Album",
|
||||
"delete_album_title": "Vymazat Album?",
|
||||
"delete_album_message": "Odstranit také fotky (a videa) přítomné v tomto albu z <a>všech</a> dalších alb, kterých jsou součástí?",
|
||||
"delete_album": "Vymazat album",
|
||||
"delete_album_title": "Vymazat album?",
|
||||
"delete_album_message": "Odstranit také fotky (a videa) přítomné v tomto albu ze <a>všech</a> dalších alb, kterých jsou součástí?",
|
||||
"delete_photos": "Odstranit fotografie",
|
||||
"keep_photos": "Ponechat fotky",
|
||||
"share_album": "Sdílet album",
|
||||
@@ -250,11 +250,11 @@
|
||||
"indexing_fetching": "Synchronizace indexů...",
|
||||
"indexing_people": "Synchronizace lidí...",
|
||||
"syncing_wait": "Synchronizování...",
|
||||
"people_empty_too_few": "Lidé se zobrazí zde, pokud jsou k dispozici dostatečné fotografie osoby",
|
||||
"people_empty_too_few": "Lidé se zde zobrazí, pokud je k dispozici dostatečné množství fotografií dané osoby",
|
||||
"unnamed_person": "Nepojmenovaná osoba",
|
||||
"add_a_name": "Přidat název",
|
||||
"add_a_name": "Přidat jméno",
|
||||
"new_person": "Nová osoba",
|
||||
"add_name": "Přidat název",
|
||||
"add_name": "Přidat jméno",
|
||||
"rename_person": "Přejmenovat osobu",
|
||||
"reset_person_confirm": "Resetovat osobu?",
|
||||
"reset_person_confirm_message": "Název, skupiny obličeje a návrhy pro tuto osobu budou resetovány",
|
||||
@@ -266,10 +266,10 @@
|
||||
"review_suggestions": "Zkontrolovat návrhy",
|
||||
"saved_choices": "Uložené volby",
|
||||
"discard_changes": "Zahodit změny",
|
||||
"discard_changes_confirm_message": "Máte neuložené změny. Tyto změny budou ztraceny, pokud zavřete bez uložení",
|
||||
"discard_changes_confirm_message": "Máte neuložené změny. Pokud zavřete bez uložení, budou ztraceny",
|
||||
"people_suggestions_finding": "Hledání podobných obličejů...",
|
||||
"people_suggestions_empty": "Zatím žádné další návrhy",
|
||||
"info": "Info",
|
||||
"info": "Informace",
|
||||
"file_name": "Název souboru",
|
||||
"caption_placeholder": "Přidat popis",
|
||||
"location": "Poloha",
|
||||
@@ -277,7 +277,7 @@
|
||||
"map": "Mapa",
|
||||
"enable_map": "Povolit mapu",
|
||||
"enable_maps_confirm": "Povolit mapu?",
|
||||
"enable_maps_confirm_message": "<p>Zobrazí vaše fotky na mapě světa.</p><p>Mapa je hostována <a>OpenStreetMap</a>a přesná umístění vašich fotografií nejsou nikdy sdílena.</p><p>Tuto funkci můžete kdykoliv zakázat v Nastavení.</p>",
|
||||
"enable_maps_confirm_message": "<p>Zobrazí vaše fotografie na mapě světa.</p><p>Mapa je hostována službou <a>OpenStreetMap</a> a přesné umístění vašich fotografií není nikdy sdíleno.</p><p>Tuto funkci můžete kdykoli deaktivovat v nastavení.</p>",
|
||||
"disable_map": "Zakázat mapu",
|
||||
"disable_maps_confirm": "Zakázat mapy?",
|
||||
"disable_maps_confirm_message": "<p>Toto zakáže zobrazení vašich fotografií na mapě světa.</p><p>Tuto funkci můžete kdykoli zapnout v nastavení.</p>",
|
||||
@@ -285,60 +285,60 @@
|
||||
"view_exif": "Zobrazit všechna Exif data",
|
||||
"no_exif": "Žádná Exif data",
|
||||
"exif": "Exif",
|
||||
"two_factor": "Dvoufaktorové",
|
||||
"two_factor_authentication": "Dvoufaktorové ověření",
|
||||
"two_factor": "Dvoufázové",
|
||||
"two_factor_authentication": "Dvoufázové ověření",
|
||||
"two_factor_qr_help": "Naskenujte QR kód pomocí vaší oblíbené autentizační aplikace",
|
||||
"two_factor_manual_entry_title": "Zadejte kód ručně",
|
||||
"two_factor_manual_entry_message": "Prosím, zadejte tento kód do vaší oblíbené autentizační aplikace",
|
||||
"scan_qr_title": "Namísto toho skenovat QR kód",
|
||||
"enable_two_factor": "",
|
||||
"enable_two_factor": "Zapnout dvoufázové ověřování",
|
||||
"enable": "Povolit",
|
||||
"enabled": "Povoleno",
|
||||
"lost_2fa_device": "",
|
||||
"lost_2fa_device": "Ztracené zařízení pro dvoufázové ověření",
|
||||
"incorrect_code": "Nesprávný kód",
|
||||
"two_factor_info": "",
|
||||
"two_factor_info": "Přidejte další vrstvu zabezpečení tím, že pro přihlášení k účtu budete vyžadovat více než jen e-mail a heslo",
|
||||
"disable": "Zakázat",
|
||||
"reconfigure": "Znovu nastavit",
|
||||
"reconfigure_two_factor_hint": "",
|
||||
"update_two_factor": "",
|
||||
"update_two_factor_message": "",
|
||||
"reconfigure_two_factor_hint": "Aktualizujte své ověřovací zařízení",
|
||||
"update_two_factor": "Aktualizace dvoufázového ověřování",
|
||||
"update_two_factor_message": "Pokračováním v procesu dojde ke zrušení všech dříve nakonfigurovaných ověřovacích prostředků",
|
||||
"update": "Aktualizovat",
|
||||
"disable_two_factor": "",
|
||||
"disable_two_factor_message": "",
|
||||
"disable_two_factor": "Vypnout dvoufázové ověřování",
|
||||
"disable_two_factor_message": "Opravdu chcete deaktivovat dvoufázové ověřování",
|
||||
"export_data": "Exportovat data",
|
||||
"select_folder": "Vyberte složku",
|
||||
"select_zips": "",
|
||||
"select_zips": "Vybrat soubory .zip",
|
||||
"faq": "Často kladené dotazy",
|
||||
"takeout_hint": "",
|
||||
"takeout_hint": "Rozbalte všechny soubory .zip do stejné složky a nahrajte ji. Nebo nahrajte soubory .zip přímo. Podrobnosti najdete v sekci Časté dotazy.",
|
||||
"destination": "Cíl",
|
||||
"start": "Spustit",
|
||||
"last_export_time": "Čas posledního exportu",
|
||||
"export_again": "",
|
||||
"local_storage_not_accessible": "",
|
||||
"email_already_taken": "E-mail je již obsazen",
|
||||
"live_photos_detected": "",
|
||||
"ignored_uploads": "",
|
||||
"ignored_uploads_hint": "",
|
||||
"export_again": "Znovu exportovat",
|
||||
"local_storage_not_accessible": "Váš prohlížeč nebo doplněk blokuje Ente v ukládání dat do lokálního úložiště",
|
||||
"email_already_taken": "Účet s tímto e-mailem již existuje",
|
||||
"live_photos_detected": "Fotografie a videa z vašich živých fotografií byly sloučeny do jednoho souboru",
|
||||
"ignored_uploads": "Ignorované nahrané soubory",
|
||||
"ignored_uploads_hint": "Tyto byly přeskočeny, protože ve stejném albu jsou soubory se stejným názvem a obsahem",
|
||||
"file_not_uploaded_list": "Následující soubory nebyly nahrány",
|
||||
"failed_uploads": "",
|
||||
"failed_uploads_hint": "",
|
||||
"retry_failed_uploads": "",
|
||||
"thumbnail_generation_failed": "",
|
||||
"thumbnail_generation_failed_hint": "Tyto soubory byly nahrány, ale bohužel jsme pro ně nemohli vygenerovat titulky.",
|
||||
"failed_uploads": "Nepodařilo se nahrát",
|
||||
"failed_uploads_hint": "Po dokončení nahrávání bude k dispozici možnost tyto soubory zkusit nahrát znovu",
|
||||
"retry_failed_uploads": "Opakujte neúspěšné nahrávání",
|
||||
"thumbnail_generation_failed": "Vytvoření miniatury se nezdařilo",
|
||||
"thumbnail_generation_failed_hint": "Tyto soubory byly nahrány, ale bohužel jsme pro ně nemohli vygenerovat miniatury.",
|
||||
"unsupported_files": "Nepodporované soubory",
|
||||
"unsupported_files_hint": "Ente zatím nepodporuje tyto formáty souborů",
|
||||
"blocked_uploads": "",
|
||||
"blocked_uploads_hint": "",
|
||||
"blocked_uploads": "Zablokované nahrávání",
|
||||
"blocked_uploads_hint": "Váš prohlížeč nebo doplněk brání Ente v používání <code>eTags</code> k nahrávání velkých souborů.",
|
||||
"large_files": "Velké soubory",
|
||||
"large_files_hint": "",
|
||||
"insufficient_storage": "",
|
||||
"insufficient_storage_hint": "",
|
||||
"uploads_in_progress": "Probíhá nahrávání",
|
||||
"large_files_hint": "Tyto soubory nebyly nahrány, protože překračují náš maximální limit velikosti souboru",
|
||||
"insufficient_storage": "Nedostatečné úložiště",
|
||||
"insufficient_storage_hint": "Tyto soubory nebyly nahrány, protože překračují maximální velikostní limit vašeho úložného tarifu",
|
||||
"uploads_in_progress": "Probíhající nahrávání",
|
||||
"successful_uploads": "Úspěšně nahrané",
|
||||
"upload_to_album": "Nahrát do alba",
|
||||
"add_to_album": "Přidat do alba",
|
||||
"move_to_album": "Přesunout do alba",
|
||||
"unhide_to_album": "Odkrýt album",
|
||||
"unhide_to_album": "Odkrýt do alba",
|
||||
"restore_to_album": "Obnovit do alba",
|
||||
"section_all": "Vše",
|
||||
"section_uncategorized": "Nezařazené",
|
||||
@@ -359,7 +359,7 @@
|
||||
"remove_from_album": "Odstranit z alba",
|
||||
"move_to_trash": "Přesunout do koše",
|
||||
"trash_files_message": "Vybrané soubory budou odstraněny ze všech alb a přesunuty do koše.",
|
||||
"trash_file_message": "Vybrané soubory budou odstraněny ze všech alb a přesunuty do koše.",
|
||||
"trash_file_message": "Soubor bude odstraněn ze všech alb a přesunut do koše.",
|
||||
"delete_permanently": "Trvale odstranit",
|
||||
"restore": "Obnovit",
|
||||
"empty_trash": "Vyprázdnit koš",
|
||||
@@ -367,9 +367,9 @@
|
||||
"empty_trash_message": "Vybrané soubory budou trvale odstraněny z vašeho účtu Ente.",
|
||||
"leave_album": "Opustit album",
|
||||
"leave_shared_album_title": "Opustit sdílené album?",
|
||||
"leave_shared_album_message": "Opustíte album a přestanete ho vidět.",
|
||||
"leave_shared_album": "Ano, odejít",
|
||||
"confirm_remove_message": "Vybrané položky budou odstraněny z tohoto alba. Položky, které jsou pouze v tomto albu budou přesunuty do nekategorizované.",
|
||||
"leave_shared_album_message": "Opustíte album a přestane být pro vás viditelné.",
|
||||
"leave_shared_album": "Ano, opustit",
|
||||
"confirm_remove_message": "Vybrané položky budou odstraněny z tohoto alba. Položky, které jsou pouze v tomto albu budou přesunuty do Nezařazené.",
|
||||
"confirm_remove_incl_others_message": "Některé položky, které odstraňujete, byly přidány jinými lidmi a k nim ztratíte přístup.",
|
||||
"oldest": "Nejstarší",
|
||||
"last_updated": "Naposledy aktualizováno",
|
||||
@@ -396,33 +396,33 @@
|
||||
"participants_count_one": "1 účastník",
|
||||
"participants_count": "{{count, number}} účastníků",
|
||||
"add_viewers": "Přidat prohlížející",
|
||||
"change_permission_to_viewer": "",
|
||||
"change_permission_to_collaborator": "",
|
||||
"change_permission_to_viewer": "<p>{{selectedEmail}} nebude moci přidávat další fotografie do alba</p><p>Stále však bude moci odstraňovat fotografie, které sám přidal</p>",
|
||||
"change_permission_to_collaborator": "{{selectedEmail}} bude moci přidávat fotografie do alba",
|
||||
"change_permission_title": "Změnit oprávnění?",
|
||||
"confirm_convert_to_viewer": "",
|
||||
"confirm_convert_to_viewer": "Ano, převést na prohlížejícího",
|
||||
"confirm_convert_to_collaborator": "Ano, převést na spolupracovníka",
|
||||
"manage": "Spravovat",
|
||||
"added_as": "",
|
||||
"collaborator_hint": "",
|
||||
"remove_participant": "",
|
||||
"remove_participant_title": "Odstranit?",
|
||||
"remove_participant_message": "",
|
||||
"added_as": "Přidán jako",
|
||||
"collaborator_hint": "Spolupracovníci mohou přidávat fotografie a videa do sdíleného alba",
|
||||
"remove_participant": "Odebrat účastníka",
|
||||
"remove_participant_title": "Odebrat?",
|
||||
"remove_participant_message": "<p>{{selectedEmail}} bude odstraněn z alba</p><p>Všechny fotografie, které přidali, budou také odstraněny</p>",
|
||||
"confirm_remove": "Ano, odstranit",
|
||||
"owner": "Vlastník",
|
||||
"collaborators": "Spolupracovníci",
|
||||
"viewers": "Prohlížející",
|
||||
"add_more": "Přidat další",
|
||||
"or_add_existing": "",
|
||||
"or_add_existing": "Nebo vyberte existující",
|
||||
"not_found": "404 - Nenalezeno",
|
||||
"link_expired": "Platnost odkazu vypršela",
|
||||
"link_expired_message": "",
|
||||
"manage_link": "",
|
||||
"link_request_limit_exceeded": "",
|
||||
"link_expired_message": "Tento odkaz vypršel nebo byl deaktivován",
|
||||
"manage_link": "Spravovat odkaz",
|
||||
"link_request_limit_exceeded": "Toto album bylo zobrazeno na příliš mnoha zařízeních",
|
||||
"allow_downloads": "Povolit stahování",
|
||||
"allow_adding_photos": "Povolit přidávání fotek",
|
||||
"allow_adding_photos_hint": "",
|
||||
"allow_adding_photos_hint": "Umožněte lidem s odkazem přidávat také fotografie do sdíleného alba.",
|
||||
"device_limit": "Limit zařízení",
|
||||
"none": "",
|
||||
"none": "Žádný",
|
||||
"link_expiry": "Platnost odkazu",
|
||||
"never": "Nikdy",
|
||||
"after_time": {
|
||||
@@ -438,15 +438,15 @@
|
||||
"remove_link": "Odstranit odkaz",
|
||||
"create_public_link": "Vytvořit veřejný odkaz",
|
||||
"public_link_created": "Veřejný odkaz vytvořen",
|
||||
"public_link_enabled": "",
|
||||
"collect_photos": "",
|
||||
"disable_file_download": "",
|
||||
"disable_file_download_message": "",
|
||||
"shared_using": "",
|
||||
"public_link_enabled": "Veřejný odkaz povolen",
|
||||
"collect_photos": "Sbírat fotky",
|
||||
"disable_file_download": "Zakázat stahování",
|
||||
"disable_file_download_message": "<p>Opravdu chcete deaktivovat tlačítko pro stahování souborů?</p><p>Uživatelé mohou i nadále pořizovat snímky obrazovky nebo ukládat kopie vašich fotografií pomocí externích nástrojů.</p>",
|
||||
"shared_using": "Sdíleno pomocí <a>{{url}}</a>",
|
||||
"sharing_referral_code": "Použij kód <strong>{{referralCode}}</strong> pro získání 10 GB zdarma",
|
||||
"disable_password": "Zakázat zamknuti heslem",
|
||||
"disable_password_message": "Jste si jisti, že chcete zakázat zamknuti heslem?",
|
||||
"password_lock": "Zamok heslem",
|
||||
"password_lock": "Zámek heslem",
|
||||
"lock": "Uzamknout",
|
||||
"file": "Soubor",
|
||||
"folder": "Složka",
|
||||
@@ -457,17 +457,17 @@
|
||||
"count": "Počet",
|
||||
"deselect_all": "Zrušte výběr všech",
|
||||
"no_duplicates": "Žádné duplikáty",
|
||||
"duplicate_group_description": "{{count}} položek, {{itemSize}} každý",
|
||||
"duplicate_group_description": "{{count}} položek, {{itemSize}} každá",
|
||||
"remove_duplicates_button_count": "Odstranit {{count, number}} položek",
|
||||
"stop_uploads_title": "Zastavit nahrávání?",
|
||||
"stop_uploads_message": "Jste si jisti, že chcete zastavit všechny probíhající nahrávání?",
|
||||
"stop_uploads_message": "Opravdu chcete zastavit všechny probíhající nahrávání?",
|
||||
"yes_stop_uploads": "Ano, zastavit nahrávání",
|
||||
"stop_downloads_title": "Zastavit nahrávání?",
|
||||
"stop_downloads_message": "Jste si jisti, že chcete zastavit všechny probíhající nahrávání?",
|
||||
"yes_stop_downloads": "Ano, zastavit nahrávání",
|
||||
"stop_downloads_message": "Opravdu chcete zastavit všechny probíhající stahování?",
|
||||
"yes_stop_downloads": "Ano, zastavit stahování",
|
||||
"albums": "Alba",
|
||||
"albums_count_one": "1 Album",
|
||||
"albums_count": "{{count, number}} alba",
|
||||
"albums_count": "{{count, number}} Alb",
|
||||
"all_albums": "Všechna alba",
|
||||
"all_hidden_albums": "Všechna skrytá alba",
|
||||
"hidden_albums": "Skrytá alba",
|
||||
@@ -486,63 +486,63 @@
|
||||
"watch_folders": "Sledovat složky",
|
||||
"watched_folders": "Sledované složky",
|
||||
"no_folders_added": "Zatím nebyly přidány žádné složky",
|
||||
"watch_folders_hint_1": "Složky, které sem přidáte, budou automaticky sledovány",
|
||||
"watch_folders_hint_1": "Složky, které sem přidáte, budou automaticky monitorovány",
|
||||
"watch_folders_hint_2": "Nahrát nové soubory na Ente",
|
||||
"watch_folders_hint_3": "Odstranit smazané soubory z Ente",
|
||||
"add_folder": "Přidat složku",
|
||||
"stop_watching": "Zastavit sledování",
|
||||
"stop_watching_folder_title": "Zastavit sledování složky?",
|
||||
"stop_watching_folder_message": "Vaše existující soubory nebudou smazány, ale Ente přestane automaticky aktualizovat připojené album Ente při změnách v této složce.",
|
||||
"stop_watching_folder_message": "Vaše stávající soubory nebudou smazány, ale Ente přestane automaticky aktualizovat propojené album Ente při změnách v této složce.",
|
||||
"yes_stop": "Ano, zastavit",
|
||||
"change_folder": "Změnit složku",
|
||||
"view_logs": "Zobrazit logy",
|
||||
"view_logs_message": "",
|
||||
"weak_device_hint": "",
|
||||
"drag_and_drop_hint": "",
|
||||
"view_logs_message": "<p>Zobrazí se ladicí protokoly, které nám můžete zaslat e-mailem, abychom vám pomohli vyřešit váš problém.</p><p>Upozorňujeme, že budou zahrnuty názvy souborů, které pomohou sledovat problémy s konkrétními soubory.</p>",
|
||||
"weak_device_hint": "Webový prohlížeč, který používáte, není dostatečně výkonný pro šifrování vašich fotografií. Zkuste se přihlásit do Ente na svém počítači nebo si stáhněte mobilní/desktopovou aplikaci Ente.",
|
||||
"drag_and_drop_hint": "Nebo přetáhněte do okna Ente",
|
||||
"authenticate": "Ověřit",
|
||||
"uploaded_to_single_collection": "",
|
||||
"uploaded_to_separate_collections": "",
|
||||
"uploaded_to_single_collection": "Nahráno do jedné sbírky",
|
||||
"uploaded_to_separate_collections": "Nahráno do samostatných sbírek",
|
||||
"nevermind": "Nevadí",
|
||||
"update_available": "Je k dispozici aktualizace",
|
||||
"update_installable_message": "",
|
||||
"update_installable_message": "Nová verze Ente je připravena k instalaci.",
|
||||
"install_now": "Nainstalovat nyní",
|
||||
"install_on_next_launch": "Instalovat při dalším spuštění",
|
||||
"update_available_message": "",
|
||||
"update_available_message": "Byla vydána nová verze programu Ente, ale nelze ji automaticky stáhnout a nainstalovat.",
|
||||
"download_and_install": "Stáhnout a nainstalovat",
|
||||
"ignore_this_version": "Ignorovat tuto verzi",
|
||||
"today": "Dnes",
|
||||
"yesterday": "Včera",
|
||||
"enter_name": "Zadejte jméno",
|
||||
"uploader_name_hint": "",
|
||||
"uploader_name_hint": "Přidejte jméno, aby vaši přátelé věděli, komu za tyto skvělé fotky poděkovat!",
|
||||
"name_placeholder": "Název...",
|
||||
"more_details": "Další podrobnosti",
|
||||
"ml_search": "Strojové učení",
|
||||
"ml_search_description": "",
|
||||
"ml_search_footnote": "",
|
||||
"ml_search_description": "Ente podporuje strojové učení na zařízení pro rozpoznávání obličejů, magické vyhledávání a další pokročilé vyhledávací funkce",
|
||||
"ml_search_footnote": "Magické vyhledávání umožňuje vyhledávat fotografie podle jejich obsahu, např. „auto“, „červené auto“, „Ferrari“",
|
||||
"indexing": "Indexování",
|
||||
"processed": "Zpracováno",
|
||||
"indexing_status_running": "",
|
||||
"indexing_status_fetching": "",
|
||||
"indexing_status_scheduled": "",
|
||||
"indexing_status_running": "Probíhá",
|
||||
"indexing_status_fetching": "Načítání",
|
||||
"indexing_status_scheduled": "Naplánováno",
|
||||
"indexing_status_done": "Hotovo",
|
||||
"ml_search_disable": "Zakázat strojové učení",
|
||||
"ml_search_disable_confirm": "",
|
||||
"ml_search_disable_confirm": "Chcete zakázat strojové učení na všech svých zařízeních?",
|
||||
"ml_consent": "Povolit strojové učení",
|
||||
"ml_consent_title": "",
|
||||
"ml_consent_description": "",
|
||||
"ml_consent_confirmation": "",
|
||||
"ml_consent_title": "Povolit strojové učení?",
|
||||
"ml_consent_description": "<p>Pokud povolíte strojové učení, Ente bude extrahovat informace, jako je geometrie obličeje, ze souborů, včetně těch, které s vámi byly sdíleny.</p><p>Toto se bude dít na vašem zařízení a veškeré generované biometrické informace budou koncově šifrovány.</p><p><a>Klikněte sem pro více informací o této funkci v našich zásadách ochrany osobních údajů</a></p>",
|
||||
"ml_consent_confirmation": "Rozumím a chci povolit strojové učení",
|
||||
"labs": "Experimentální",
|
||||
"password_strength_weak": "Síla hesla: Slabá",
|
||||
"password_strength_moderate": "",
|
||||
"password_strength_moderate": "Síla hesla: Střední",
|
||||
"password_strength_strong": "Síla hesla: Silná",
|
||||
"preferences": "Předvolby",
|
||||
"language": "Jazyk",
|
||||
"advanced": "Pokročilé",
|
||||
"export_directory_does_not_exist": "",
|
||||
"export_directory_does_not_exist_message": "",
|
||||
"export_directory_does_not_exist": "Neplatný adresář pro export",
|
||||
"export_directory_does_not_exist_message": "<p>Zvolený adresář pro export neexistuje.</p><p>Zvolte platný adresář.</p>",
|
||||
"storage_unit": {
|
||||
"b": "B",
|
||||
"kb": "kB",
|
||||
"kb": "KB",
|
||||
"mb": "MB",
|
||||
"gb": "GB",
|
||||
"tb": "TB"
|
||||
@@ -554,27 +554,27 @@
|
||||
"export_renaming_album_folders": "Přejmenování složek alba...",
|
||||
"export_trashing_deleted_files": "Mazání odstraněných souborů...",
|
||||
"export_trashing_deleted_albums": "Mazání odstraněných alb...",
|
||||
"export_progress": "<a>{{progress.success, number}} / {{progress.total, number}}</a> položky synchronizovány",
|
||||
"export_progress": "<a>{{progress.success, number}} / {{progress.total, number}}</a> položek synchronizováno",
|
||||
"pending_items": "Čekající položky",
|
||||
"delete_account_reason_label": "Jaký je váš hlavní důvod, proč mažete svůj účet?",
|
||||
"delete_account_reason_placeholder": "Vybrat důvod",
|
||||
"delete_reason": {
|
||||
"missing_feature": "Chybí klíčová funkce, kterou potřebuji",
|
||||
"behaviour": "Aplikace nebo určitá funkce se nechová tak, jak si myslím, že by měla",
|
||||
"found_another_service": "Našel jsem další službu, která se mi líbí víc",
|
||||
"found_another_service": "Našel jsem jinou službu, která se mi líbí víc",
|
||||
"not_listed": "Můj důvod není uveden"
|
||||
},
|
||||
"delete_account_feedback_label": "Je nám líto, že odcházíte. Vysvětlete prosim, proč odcházíte, abyste nám pomohli zlepšit.",
|
||||
"delete_account_feedback_label": "Je nám líto, že odcházíte. Prosím, vysvětlete nám důvod svého odchodu, abychom se mohli zlepšit.",
|
||||
"delete_account_feedback_placeholder": "Zpětná vazba",
|
||||
"delete_account_confirm_checkbox_label": "Ano, chci trvale odstranit tento účet a všechny jeho údaje",
|
||||
"delete_account_confirm": "Potvrdit odstranění účtu",
|
||||
"delete_account_confirm_message": "<p>Tento účet je propojen s ostatními Ente aplikacemi, pokud jej používáte.</p><p>Vaše nahraná data napříč všemi Ente aplikacemi budou naplánována na smazání a váš účet bude trvale smazán.</p>",
|
||||
"feedback_required": "Pomozte nám s těmito informacemi",
|
||||
"feedback_required_found_another_service": "Co ostatni služba dela lépe?",
|
||||
"recover_two_factor": "Obnovit dvoufaktorové",
|
||||
"delete_account_confirm_message": "<p>Tento účet je propojen s dalšími aplikacemi Ente, pokud je používáte.</p><p>Vaše nahraná data ve všech aplikacích Ente budou naplánována k odstranění a váš účet bude trvale smazán.</p>",
|
||||
"feedback_required": "Prosíme, pomozte nám s těmito informacemi",
|
||||
"feedback_required_found_another_service": "V čem je ta druhá služba lepší?",
|
||||
"recover_two_factor": "Obnovit dvoufázové ověření",
|
||||
"at": "na",
|
||||
"auth_next": "další",
|
||||
"auth_download_mobile_app": "Stáhněte si naši mobilní aplikaci pro správu vašich tajemství",
|
||||
"auth_download_mobile_app": "Stáhněte si naši mobilní aplikaci a spravujte svá tajemství",
|
||||
"no_codes_added_yet": "Zatím nebyly přidány žádné kódy",
|
||||
"hide": "Skrýt",
|
||||
"unhide": "Zobrazit",
|
||||
@@ -591,15 +591,15 @@
|
||||
"christmas": "Vánoce",
|
||||
"christmas_eve": "Vánoční večer",
|
||||
"new_year": "Nový rok",
|
||||
"new_year_eve": "Nový rok",
|
||||
"new_year_eve": "Silvestr",
|
||||
"image": "Obrázek",
|
||||
"video": "Video",
|
||||
"live_photo": "Živá fotografie",
|
||||
"live": "",
|
||||
"edit_image": "",
|
||||
"photo_editor": "",
|
||||
"confirm_editor_close": "",
|
||||
"confirm_editor_close_message": "",
|
||||
"live": "Živé",
|
||||
"edit_image": "Upravit obrázek",
|
||||
"photo_editor": "Editor obrázků",
|
||||
"confirm_editor_close": "Opravdu chcete editor zavřít?",
|
||||
"confirm_editor_close_message": "Stáhněte si upravený obrázek nebo uložte kopii do Ente, aby se změny trvale uložily.",
|
||||
"brightness": "Jas",
|
||||
"contrast": "Kontrast",
|
||||
"saturation": "Sytost",
|
||||
@@ -609,93 +609,93 @@
|
||||
"aspect_ratio": "Poměr stran",
|
||||
"square": "Čtverec",
|
||||
"freehand": "Volný",
|
||||
"apply_crop": "",
|
||||
"apply_crop": "Použít ořez",
|
||||
"rotation": "Otočení",
|
||||
"rotate_left": "Otočit doleva",
|
||||
"rotate_right": "Otočit doprava",
|
||||
"flip": "Překlopit",
|
||||
"flip_vertically": "Překlopit svisle",
|
||||
"flip_horizontally": "Překlopit vodorovně",
|
||||
"download_edited": "",
|
||||
"download_edited": "Stáhnout upravené",
|
||||
"save_a_copy_to_ente": "Uložit kopii na Ente",
|
||||
"restore_original": "Obnovit původní",
|
||||
"photo_edit_required_to_save": "",
|
||||
"restore_original": "Obnovit originál",
|
||||
"photo_edit_required_to_save": "Před uložením je nutné provést alespoň jednu transformaci nebo úpravu barev.",
|
||||
"colors": "Barvy",
|
||||
"invert_colors": "Invertovat barvy",
|
||||
"reset": "Obnovit",
|
||||
"faster_upload": "Rychlejší nahrávání",
|
||||
"faster_upload_description": "",
|
||||
"open_ente_on_startup": "",
|
||||
"faster_upload_description": "Provést nahrávání skrz servery v blízkosti",
|
||||
"open_ente_on_startup": "Otevřít Ente při spuštění",
|
||||
"cast_album_to_tv": "Přehrát album v televizi",
|
||||
"cast_to_tv": "",
|
||||
"enter_cast_pin_code": "",
|
||||
"cast_to_tv": "Přehrát v televizi",
|
||||
"enter_cast_pin_code": "Zadejte kód, který vidíte na televizoru, abyste spárovali toto zařízení.",
|
||||
"code": "Kód",
|
||||
"pair_device_to_tv": "Párovat zařízení",
|
||||
"tv_not_found": "",
|
||||
"cast_auto_pair": "",
|
||||
"cast_auto_pair_description": "",
|
||||
"choose_device_from_browser": "",
|
||||
"cast_auto_pair_failed": "",
|
||||
"tv_not_found": "Televizor nebyl nalezen. Zadali jste správně PIN kód?",
|
||||
"cast_auto_pair": "Automatické párování",
|
||||
"cast_auto_pair_description": "Funkce automatického párování funguje pouze se zařízeními, která podporují Chromecast.",
|
||||
"choose_device_from_browser": "Vyberte zařízení kompatibilní s Chromecast z vyskakovacího okna prohlížeče.",
|
||||
"cast_auto_pair_failed": "Párování Chromecastu se nezdařilo. Zkuste to znovu.",
|
||||
"pair_with_pin": "Párovat s PINem",
|
||||
"pair_with_pin_description": "",
|
||||
"visit_cast_url": "",
|
||||
"passkeys": "",
|
||||
"passkey_fetch_failed": "",
|
||||
"manage_passkey": "",
|
||||
"delete_passkey": "",
|
||||
"delete_passkey_confirmation": "",
|
||||
"rename_passkey": "",
|
||||
"add_passkey": "",
|
||||
"enter_passkey_name": "Zadejte název přístupového hesla",
|
||||
"passkeys_description": "Passkeys je moderní a bezpečný druhý faktor pro váš účet Ente. Používají biometrické ověření na zařízení pro pohodlí a bezpečnost.",
|
||||
"pair_with_pin_description": "Funkce \"Párovat s PINem\" funguje s jakoukoli obrazovkou, na které si chcete prohlížet své album.",
|
||||
"visit_cast_url": "Navštivte <a>{{url}}</a> na zařízení, které chcete spárovat.",
|
||||
"passkeys": "Přístupové klíče",
|
||||
"passkey_fetch_failed": "Nelze získat vaše přístupové klíče.",
|
||||
"manage_passkey": "Spravovat přístupové klíče",
|
||||
"delete_passkey": "Smazat přístupový klíč",
|
||||
"delete_passkey_confirmation": "Opravdu chcete tento přístupový klíč smazat? Tato akce je nevratná.",
|
||||
"rename_passkey": "Přejmenovat přístupový klíč",
|
||||
"add_passkey": "Přidat přístupový klíč",
|
||||
"enter_passkey_name": "Zadejte název přístupového klíče",
|
||||
"passkeys_description": "Přístupového klíče jsou moderní a bezpečné dvoufázové ověření pro váš účet Ente. Využívají biometrické ověření na zařízení pro větší pohodlí a bezpečnost.",
|
||||
"created_at": "Vytvořeno dne",
|
||||
"passkey_add_failed": "Nelze přidat passkey",
|
||||
"passkey_login_failed": "Přihlášení pomocí passkey selhalo",
|
||||
"passkey_add_failed": "Nelze přidat přístupový klíč",
|
||||
"passkey_login_failed": "Přihlášení pomocí přístupového klíče selhalo",
|
||||
"passkey_login_invalid_url": "Přihlašovací URL je neplatná.",
|
||||
"passkey_login_already_claimed_session": "Tato relace již byla ověřena.",
|
||||
"passkey_login_generic_error": "Došlo k chybě při přihlášení s passkey.",
|
||||
"passkey_login_credential_hint": "Pokud je vaše passkey na jiném zařízení, můžete tuto stránku otevřít na tomto zařízení a ověřit.",
|
||||
"passkeys_not_supported": "Passkeys nejsou v tomto prohlížeči podporovány",
|
||||
"passkey_login_generic_error": "Došlo k chybě při přihlášení pomocí přístupového klíče.",
|
||||
"passkey_login_credential_hint": "Pokud jsou vaše přístupové klíče na jiném zařízení, můžete tuto stránku otevřít na daném zařízení a ověřit je.",
|
||||
"passkeys_not_supported": "Tento prohlížeč nepodporuje přístupové klíče",
|
||||
"try_again": "Zkusit znovu",
|
||||
"check_status": "Zkontrolovat stav",
|
||||
"passkey_login_instructions": "Postupujte podle kroků vašeho prohlížeče a pokračujte v přihlašování.",
|
||||
"passkey_login": "Přihlásit se pomocí passkey",
|
||||
"passkey_login": "Přihlášení pomocí přístupového klíče",
|
||||
"totp_login": "Přihlášení s TOTP",
|
||||
"passkey": "Passkey",
|
||||
"passkey_verify_description": "Ověřte své passkey pro přihlášení do vašeho účtu.",
|
||||
"passkey": "Přístupový klíč",
|
||||
"passkey_verify_description": "Ověřte svůj přístupový klíč pro přihlášení do svého účtu.",
|
||||
"waiting_for_verification": "Čekám na ověření...",
|
||||
"verification_still_pending": "Ověřování stále probíhá",
|
||||
"passkey_verified": "Passkey ověřen",
|
||||
"passkey_verified": "Přístupový klíč ověřen",
|
||||
"redirecting_back_to_app": "Přesměrování zpět do aplikace...",
|
||||
"redirect_close_instructions": "Toto okno můžete zavřít po otevření aplikace.",
|
||||
"redirect_again": "Přesměrovat znovu",
|
||||
"autogenerated_first_album_name": "Moje první alba",
|
||||
"autogenerated_default_album_name": "Nový album",
|
||||
"developer_settings": "Vývojářská nastavení",
|
||||
"autogenerated_first_album_name": "Moje první album",
|
||||
"autogenerated_default_album_name": "Nové album",
|
||||
"developer_settings": "Nastavení pro vývojáře",
|
||||
"server_endpoint": "Koncový bod serveru",
|
||||
"more_information": "Více informací",
|
||||
"save": "Uložit",
|
||||
"theme": "Téma",
|
||||
"theme": "Motiv",
|
||||
"system": "Systém",
|
||||
"light": "Světlý",
|
||||
"dark": "Tmavý",
|
||||
"streamable_videos": "",
|
||||
"processing_videos_status": "",
|
||||
"share_favorites": "",
|
||||
"person_favorites": "",
|
||||
"shared_favorites": "",
|
||||
"added_by_name": "",
|
||||
"unowned_files_not_processed": "",
|
||||
"custom_domains": "",
|
||||
"custom_domains_desc": "",
|
||||
"link_your_domain": "",
|
||||
"domain": "",
|
||||
"domain_help": "",
|
||||
"invalid_domain": "",
|
||||
"already_linked_domain": "",
|
||||
"add_dns_entry": "",
|
||||
"add_dns_entry_hint": "",
|
||||
"custom_domains_help": "",
|
||||
"num_1": "",
|
||||
"num_2": ""
|
||||
"streamable_videos": "Videa pro streamování",
|
||||
"processing_videos_status": "Zpracování videí...",
|
||||
"share_favorites": "Sdílet oblíbené",
|
||||
"person_favorites": "Oblíbené položky uživatele {{name}}",
|
||||
"shared_favorites": "Sdílené oblíbené položky",
|
||||
"added_by_name": "Přidáno uživatelem {{name}}",
|
||||
"unowned_files_not_processed": "Soubory přidané jinými uživateli nebyly zpracovány",
|
||||
"custom_domains": "Vlastní domény",
|
||||
"custom_domains_desc": "Při sdílení používejte vlastní doménu",
|
||||
"link_your_domain": "Propojte svou doménu",
|
||||
"domain": "Doména",
|
||||
"domain_help": "Jakákoli doména nebo subdoména, kterou vlastníte",
|
||||
"invalid_domain": "Neplatná doména",
|
||||
"already_linked_domain": "Doména již propojena uživatelem",
|
||||
"add_dns_entry": "Přidat záznam DNS",
|
||||
"add_dns_entry_hint": "U svého poskytovatele DNS přidejte CNAME ze své domény na <b>{{host}}</b>",
|
||||
"custom_domains_help": "Další informace naleznete v <a>nápovědě</a>",
|
||||
"num_1": "1",
|
||||
"num_2": "2"
|
||||
}
|
||||
|
||||
@@ -686,16 +686,16 @@
|
||||
"shared_favorites": "Geteilte Favoriten",
|
||||
"added_by_name": "Von {{name}} hinzugefügt",
|
||||
"unowned_files_not_processed": "Von anderen Benutzern hinzugefügte Dateien wurden nicht verarbeitet",
|
||||
"custom_domains": "",
|
||||
"custom_domains_desc": "",
|
||||
"link_your_domain": "",
|
||||
"domain": "",
|
||||
"domain_help": "",
|
||||
"invalid_domain": "",
|
||||
"already_linked_domain": "",
|
||||
"add_dns_entry": "",
|
||||
"add_dns_entry_hint": "",
|
||||
"custom_domains_help": "",
|
||||
"num_1": "",
|
||||
"num_2": ""
|
||||
"custom_domains": "Benutzerdefinierte Domain",
|
||||
"custom_domains_desc": "Verwenden Sie Ihre eigene Domain beim Teilen",
|
||||
"link_your_domain": "Domain verknüpfen",
|
||||
"domain": "Domain",
|
||||
"domain_help": "Eine beliebige Domain oder Subdomain die Sie besitzen",
|
||||
"invalid_domain": "Ungültige Domain",
|
||||
"already_linked_domain": "Domain wurde von einem Benutzer bereits verknüpft",
|
||||
"add_dns_entry": "DNS-Eintrag hinzufügen",
|
||||
"add_dns_entry_hint": "Fügen Sie bei Ihrem DNS-Provider einen CNAME von Ihrer Domain zu <b>{{host}}</b> hinzu",
|
||||
"custom_domains_help": "Weitere Informationen finden Sie unter <a>Hilfe</a>",
|
||||
"num_1": "1",
|
||||
"num_2": "2"
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
"ok": "OK",
|
||||
"success": "Sukces",
|
||||
"error": "Błąd",
|
||||
"note": "",
|
||||
"note": "Notatka",
|
||||
"offline_message": "Jesteś offline, wyświetlane są wspomnienia z pamięci podręcznej",
|
||||
"install": "Zainstaluj",
|
||||
"install_mobile_app": "Zainstaluj naszą aplikację na system <a>Android</a> lub <b>iOS</b>, aby automatycznie wykonać kopię zapasową wszystkich zdjęć",
|
||||
@@ -686,16 +686,16 @@
|
||||
"shared_favorites": "Udostępnione ulubione",
|
||||
"added_by_name": "Dodane przez {{name}}",
|
||||
"unowned_files_not_processed": "Pliki dodane przez innych użytkowników nie były przetwarzane",
|
||||
"custom_domains": "",
|
||||
"custom_domains_desc": "",
|
||||
"link_your_domain": "",
|
||||
"domain": "",
|
||||
"domain_help": "",
|
||||
"invalid_domain": "",
|
||||
"already_linked_domain": "",
|
||||
"add_dns_entry": "",
|
||||
"add_dns_entry_hint": "",
|
||||
"custom_domains_help": "",
|
||||
"custom_domains": "Domeny własne",
|
||||
"custom_domains_desc": "Podczas udostępniania użyj własnej domeny",
|
||||
"link_your_domain": "Połącz swoją domenę",
|
||||
"domain": "Domena",
|
||||
"domain_help": "Dowolna domena lub subdomena, którą posiadasz",
|
||||
"invalid_domain": "Domena nieprawidłowa",
|
||||
"already_linked_domain": "Domena jest już połączona przez użytkownika",
|
||||
"add_dns_entry": "Dodaj wpis DNS",
|
||||
"add_dns_entry_hint": "Na swoim dostawcy DNS, dodaj CNAME ze swojej domeny do <b>{{host}}</b>",
|
||||
"custom_domains_help": "Aby uzyskać więcej informacji, zobacz <a>pomoc</a>",
|
||||
"num_1": "",
|
||||
"num_2": ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user