diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 29e33082..fee2366c 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -470,7 +470,16 @@ Système Suit ton téléphone Réglages + %1$s disponible | %1$s disponibles + %1$s refusée | %1$s refusées + %1$s épisode regardé | %1$s épisodes regardés + %1$s film | %1$s films + %1$s lecture | %1$s lectures + %1$s série | %1$s séries + %1$s titre | %1$s titres + %1$s titre de la bibliothèque que personne n’a jamais lu, les plus anciens d’abord (admins uniquement). | %1$s titres de la bibliothèque que personne n’a jamais lus, les plus anciens d’abord (admins uniquement). Statistiques + et %1$s autre | et %1$s autres %1$s films et %2$s séries connus dans la bibliothèque. Toutes les %s Dernier lancement %s diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 70bd718f..75a2a335 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -470,7 +470,16 @@ System Follows your phone Settings + %1$s available | %1$s available + %1$s declined | %1$s declined + %1$s episode watched | %1$s episodes watched + %1$s movie | %1$s movies + %1$s play | %1$s plays + %1$s TV show | %1$s TV shows + %1$s title | %1$s titles + %1$s title in the library that nobody ever played, oldest first (admins only). | %1$s titles in the library that nobody ever played, oldest first (admins only). Statistics + and %1$s more | and %1$s more %1$s movies and %2$s TV shows known in the library. Runs every %s Last run %s diff --git a/backend/internal/api/users.go b/backend/internal/api/users.go index 7399ebfb..e8e01010 100644 --- a/backend/internal/api/users.go +++ b/backend/internal/api/users.go @@ -3,6 +3,7 @@ package api import ( "context" "github.com/Rombond/tipsarr/backend/internal/teasers" + "log/slog" "net/http" "regexp" @@ -102,7 +103,11 @@ func registerUsers(api huma.API, d Deps) { return nil, err } if u.Language != before && d.Suggestions != nil { - d.Suggestions.QueueRefresh(u.ID) // stored suggestion rows hold titles in the old language + // stored suggestion rows hold titles in the old language: drop them, the next read builds them in the new one + if err := d.Suggestions.Forget(ctx, u.ID); err != nil { + slog.Warn("suggestions: forget after a language change", "user", u.ID, "err", err) + d.Suggestions.QueueRefresh(u.ID) + } } return &meOutput{Body: u}, nil }) diff --git a/backend/internal/server/suggestions_test.go b/backend/internal/server/suggestions_test.go index 76389621..4931144c 100644 --- a/backend/internal/server/suggestions_test.go +++ b/backend/internal/server/suggestions_test.go @@ -92,6 +92,19 @@ func TestSuggestions(t *testing.T) { t.Fatal("reading stored suggestions must not call TMDB") } + // a new language drops the stored rows (they hold titles in the old one): the next read builds them again at once + if resp, body := call(t, e.app, "PATCH", "/api/v1/me", `{"language":"fr"}`, admin); resp.StatusCode != 200 { + t.Fatalf("language change = %d %s", resp.StatusCode, body) + } + if stored, _, _ := e.st.SuggestionRows(ctx, alice); len(stored) != 0 { + t.Fatalf("rows kept after a language change: %d", len(stored)) + } + if again, _ := getSuggestions(t, e, admin); len(again.Rows) != 3 || again.Generating { + t.Fatalf("read after a language change = %+v", again) + } + call(t, e.app, "PATCH", "/api/v1/me", `{"language":""}`, admin) + getSuggestions(t, e, admin) + // bob has no history: seeded from what the server watches, no "because" rows bres, _ := getSuggestions(t, e, bob) if len(bres.Rows) != 1 || bres.Rows[0].Title != "Popular on this server" || bres.Rows[0].Personal { diff --git a/backend/internal/suggestions/service.go b/backend/internal/suggestions/service.go index e7cebbb9..2ee1f2da 100644 --- a/backend/internal/suggestions/service.go +++ b/backend/internal/suggestions/service.go @@ -292,6 +292,17 @@ func (s *Service) Force(ctx context.Context, userID string) error { return nil } +// Forget drops the stored rows of one user, so the next read builds them again (synchronously, like a first visit). +// Used when their language changed: the rows hold titles, posters and overviews in the old one, and a background +// refresh would leave them on screen until a suggestions.updated event reached the app. It waits for a running +// Generate, which would otherwise store rows in the old language after this. +func (s *Service) Forget(ctx context.Context, userID string) error { + lock := s.userLock(userID) + lock.Lock() + defer lock.Unlock() + return s.store.ReplaceSuggestions(ctx, userID, nil, nil) +} + // QueueRefresh schedules a debounced background regeneration (used when history changed), for the person and for // everyone whose recommendations borrow from them. The version check already refreshes those rows when they are next // read; this only makes it sooner. diff --git a/design/generated/Localizable.xcstrings b/design/generated/Localizable.xcstrings index a6fe309c..9eaf2199 100644 --- a/design/generated/Localizable.xcstrings +++ b/design/generated/Localizable.xcstrings @@ -4539,13 +4539,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Signing out removes an account from this iPhone." + "value": "Signing out removes an account from this device." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Se déconnecter retire un compte de cet iPhone." + "value": "Se déconnecter retire un compte de cet appareil." } } } @@ -7123,13 +7123,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Your password is kept in the iPhone’s Keychain, protected by Face ID or your passcode. It is only used to sign you back in when your session ends." + "value": "Your password is kept in the device’s Keychain, protected by Face ID or your passcode. It is only used to sign you back in when your session ends." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Ton mot de passe est conservé dans le trousseau de l’iPhone, protégé par Face ID ou ton code. Il sert uniquement à te reconnecter quand ta session se termine." + "value": "Ton mot de passe est conservé dans le trousseau de l’appareil, protégé par Face ID ou ton code. Il sert uniquement à te reconnecter quand ta session se termine." } } } @@ -7155,13 +7155,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Default (iPhone language)" + "value": "Default (device language)" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Par défaut (langue de l’iPhone)" + "value": "Par défaut (langue de l’appareil)" } } } @@ -7459,13 +7459,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Sign out of Tipsarr on this iPhone?" + "value": "Sign out of Tipsarr on this device?" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Se déconnecter de Tipsarr sur cet iPhone ?" + "value": "Se déconnecter de Tipsarr sur cet appareil ?" } } } @@ -7507,13 +7507,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Follows your iPhone" + "value": "Follows your device" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Suit ton iPhone" + "value": "Suit ton appareil" } } } @@ -7534,6 +7534,142 @@ } } }, + "m.stats.n_available": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ available | %1$@ available" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ disponible | %1$@ disponibles" + } + } + } + }, + "m.stats.n_declined": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ declined | %1$@ declined" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ refusée | %1$@ refusées" + } + } + } + }, + "m.stats.n_episodes": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ episode watched | %1$@ episodes watched" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ épisode regardé | %1$@ épisodes regardés" + } + } + } + }, + "m.stats.n_movies": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ movie | %1$@ movies" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ film | %1$@ films" + } + } + } + }, + "m.stats.n_plays": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ play | %1$@ plays" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ lecture | %1$@ lectures" + } + } + } + }, + "m.stats.n_shows": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ TV show | %1$@ TV shows" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ série | %1$@ séries" + } + } + } + }, + "m.stats.n_titles": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ title | %1$@ titles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ titre | %1$@ titres" + } + } + } + }, + "m.stats.never_hint": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ title in the library that nobody ever played, oldest first (admins only). | %1$@ titles in the library that nobody ever played, oldest first (admins only)." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ titre de la bibliothèque que personne n’a jamais lu, les plus anciens d’abord (admins uniquement). | %1$@ titres de la bibliothèque que personne n’a jamais lus, les plus anciens d’abord (admins uniquement)." + } + } + } + }, "m.stats.open": { "localizations": { "en": { @@ -7550,6 +7686,23 @@ } } }, + "m.stats.unwatched_more": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "and %1$@ more | and %1$@ more" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "et %1$@ autre | et %1$@ autres" + } + } + } + }, "m.sync.counts": { "localizations": { "en": { diff --git a/design/generated/android/values-fr/strings.xml b/design/generated/android/values-fr/strings.xml index 29e33082..fee2366c 100644 --- a/design/generated/android/values-fr/strings.xml +++ b/design/generated/android/values-fr/strings.xml @@ -470,7 +470,16 @@ Système Suit ton téléphone Réglages + %1$s disponible | %1$s disponibles + %1$s refusée | %1$s refusées + %1$s épisode regardé | %1$s épisodes regardés + %1$s film | %1$s films + %1$s lecture | %1$s lectures + %1$s série | %1$s séries + %1$s titre | %1$s titres + %1$s titre de la bibliothèque que personne n’a jamais lu, les plus anciens d’abord (admins uniquement). | %1$s titres de la bibliothèque que personne n’a jamais lus, les plus anciens d’abord (admins uniquement). Statistiques + et %1$s autre | et %1$s autres %1$s films et %2$s séries connus dans la bibliothèque. Toutes les %s Dernier lancement %s diff --git a/design/generated/android/values/strings.xml b/design/generated/android/values/strings.xml index 70bd718f..75a2a335 100644 --- a/design/generated/android/values/strings.xml +++ b/design/generated/android/values/strings.xml @@ -470,7 +470,16 @@ System Follows your phone Settings + %1$s available | %1$s available + %1$s declined | %1$s declined + %1$s episode watched | %1$s episodes watched + %1$s movie | %1$s movies + %1$s play | %1$s plays + %1$s TV show | %1$s TV shows + %1$s title | %1$s titles + %1$s title in the library that nobody ever played, oldest first (admins only). | %1$s titles in the library that nobody ever played, oldest first (admins only). Statistics + and %1$s more | and %1$s more %1$s movies and %2$s TV shows known in the library. Runs every %s Last run %s diff --git a/design/generated/strings.json b/design/generated/strings.json index a80abe3c..cc83dcc1 100644 --- a/design/generated/strings.json +++ b/design/generated/strings.json @@ -709,17 +709,17 @@ "m.accounts.add": "Add account", "m.accounts.manage": "Manage accounts", "m.accounts.switching": "Switching to {name}…", - "m.accounts.footer_signout": "Signing out removes an account from this iPhone.", + "m.accounts.footer_signout": "Signing out removes an account from this device.", "m.accounts.intro": "Each account keeps its own sign-in.", "m.accounts.server": "Server", "m.accounts.current_server": "Current server", "m.accounts.other_server": "Another server…", "m.faceid.usage": "Tipsarr uses Face ID to unlock the app.", "m.settings.theme_system": "System", - "m.settings.theme_system_hint": "Follows your iPhone", + "m.settings.theme_system_hint": "Follows your device", "m.settings.theme_footer": "Choose how Tipsarr looks. System follows the appearance set in iOS.", - "m.settings.language_default": "Default (iPhone language)", - "m.settings.sign_out_confirm": "Sign out of Tipsarr on this iPhone?", + "m.settings.language_default": "Default (device language)", + "m.settings.sign_out_confirm": "Sign out of Tipsarr on this device?", "m.picture.library": "Choose from library", "m.picture.footer": "Pictures are shared with other people on this server. Max 4 MB.", "m.detail.open_jellyfin": "Open in Jellyfin", @@ -805,7 +805,7 @@ "m.session.use_password": "Use my password", "m.session.signing_in_faceid": "Signing in…", "m.settings.faceid_signin": "Sign in with Face ID", - "m.settings.faceid_signin_footer": "Your password is kept in the iPhone’s Keychain, protected by Face ID or your passcode. It is only used to sign you back in when your session ends.", + "m.settings.faceid_signin_footer": "Your password is kept in the device’s Keychain, protected by Face ID or your passcode. It is only used to sign you back in when your session ends.", "m.detail.share": "Share", "m.admin.title": "Administration", "m.admin.sync": "Sync & jobs", @@ -865,6 +865,15 @@ "m.accounts.title_one": "Account", "m.settings.hidden": "Hidden content", "m.stats.open": "Statistics", + "m.stats.n_plays": "{count} play | {count} plays", + "m.stats.n_episodes": "{count} episode watched | {count} episodes watched", + "m.stats.n_titles": "{count} title | {count} titles", + "m.stats.n_movies": "{count} movie | {count} movies", + "m.stats.n_shows": "{count} TV show | {count} TV shows", + "m.stats.n_available": "{count} available | {count} available", + "m.stats.n_declined": "{count} declined | {count} declined", + "m.stats.unwatched_more": "and {count} more | and {count} more", + "m.stats.never_hint": "{count} title in the library that nobody ever played, oldest first (admins only). | {count} titles in the library that nobody ever played, oldest first (admins only).", "m.library.under": "under {duration}", "m.library.close": "Close", "m.library.genre_all": "All selected", @@ -1726,17 +1735,17 @@ "m.accounts.add": "Ajouter un compte", "m.accounts.manage": "Gérer les comptes", "m.accounts.switching": "Passage à {name}…", - "m.accounts.footer_signout": "Se déconnecter retire un compte de cet iPhone.", + "m.accounts.footer_signout": "Se déconnecter retire un compte de cet appareil.", "m.accounts.intro": "Chaque compte garde sa connexion.", "m.accounts.server": "Serveur", "m.accounts.current_server": "Serveur actuel", "m.accounts.other_server": "Un autre serveur…", "m.faceid.usage": "Tipsarr utilise Face ID pour déverrouiller l’app.", "m.settings.theme_system": "Système", - "m.settings.theme_system_hint": "Suit ton iPhone", + "m.settings.theme_system_hint": "Suit ton appareil", "m.settings.theme_footer": "Choisis l’apparence de Tipsarr. Système suit l’apparence réglée dans iOS.", - "m.settings.language_default": "Par défaut (langue de l’iPhone)", - "m.settings.sign_out_confirm": "Se déconnecter de Tipsarr sur cet iPhone ?", + "m.settings.language_default": "Par défaut (langue de l’appareil)", + "m.settings.sign_out_confirm": "Se déconnecter de Tipsarr sur cet appareil ?", "m.picture.library": "Choisir dans la photothèque", "m.picture.footer": "Les photos sont visibles par les autres personnes de ce serveur. 4 Mo maximum.", "m.detail.open_jellyfin": "Ouvrir dans Jellyfin", @@ -1822,7 +1831,7 @@ "m.session.use_password": "Utiliser mon mot de passe", "m.session.signing_in_faceid": "Connexion…", "m.settings.faceid_signin": "Se connecter avec Face ID", - "m.settings.faceid_signin_footer": "Ton mot de passe est conservé dans le trousseau de l’iPhone, protégé par Face ID ou ton code. Il sert uniquement à te reconnecter quand ta session se termine.", + "m.settings.faceid_signin_footer": "Ton mot de passe est conservé dans le trousseau de l’appareil, protégé par Face ID ou ton code. Il sert uniquement à te reconnecter quand ta session se termine.", "m.detail.share": "Partager", "m.admin.title": "Administration", "m.admin.sync": "Synchro et tâches", @@ -1882,6 +1891,15 @@ "m.accounts.title_one": "Compte", "m.settings.hidden": "Contenu masqué", "m.stats.open": "Statistiques", + "m.stats.n_plays": "{count} lecture | {count} lectures", + "m.stats.n_episodes": "{count} épisode regardé | {count} épisodes regardés", + "m.stats.n_titles": "{count} titre | {count} titres", + "m.stats.n_movies": "{count} film | {count} films", + "m.stats.n_shows": "{count} série | {count} séries", + "m.stats.n_available": "{count} disponible | {count} disponibles", + "m.stats.n_declined": "{count} refusée | {count} refusées", + "m.stats.unwatched_more": "et {count} autre | et {count} autres", + "m.stats.never_hint": "{count} titre de la bibliothèque que personne n’a jamais lu, les plus anciens d’abord (admins uniquement). | {count} titres de la bibliothèque que personne n’a jamais lus, les plus anciens d’abord (admins uniquement).", "m.library.under": "moins de {duration}", "m.library.close": "Fermer", "m.library.genre_all": "Tous les genres", diff --git a/docs/mobile-apps.md b/docs/mobile-apps.md index fea5d50d..cda99fd0 100644 --- a/docs/mobile-apps.md +++ b/docs/mobile-apps.md @@ -18,6 +18,12 @@ To try the apps without a server, use the [demo](demo.md) address `https://tipsa as `admin` / `demo` (admin rights) or `user` / `demo` (plain user). Signing in starts your own sandbox; when it ends (30 minutes idle, 60 minutes at most) the app returns to its sign-in screen. Push notifications are off in the demo. +## Language and region + +The apps show titles and descriptions in the phone's language. The iOS app also takes the phone's region (release +dates, box office) until you pick one in Settings > Region: after you change the phone's language and region, the +next launch uses the new ones. A region you picked (in the app, on the web or on Android) is kept. + ## Notifications See [push notifications](push-notifications.md). The admin turns push on for the server; each person then allows diff --git a/ios/Tipsarr/Tipsarr/App/MainTabView.swift b/ios/Tipsarr/Tipsarr/App/MainTabView.swift index dac2d4d6..87599c2e 100644 --- a/ios/Tipsarr/Tipsarr/App/MainTabView.swift +++ b/ios/Tipsarr/Tipsarr/App/MainTabView.swift @@ -51,7 +51,8 @@ struct MainTabView: View { router.profileOpen = false router.tab = .requests } - .tipsarrSheet(detents: [.large]) + // Page size on iPad: the form size cut the most-watched posters and their captions, and a form sheet does not grow. + .tipsarrSheet(detents: [.large], sizing: .page) } .sheet(isPresented: $router.settingsOpen) { SettingsScreen(account: account, profile: profile, path: $router.settingsPath) { router.settingsOpen = false } @@ -86,6 +87,11 @@ struct MainTabView: View { for teaser in teasersModel.items.prefix(2) { _ = teaserVideos.video(for: teaser) } } .onChange(of: profile.ratingSource) { _, new in ratings.setSource(RatingSource(rawValue: new) ?? .tmdb) } + .onChange(of: session.signingOut) { _, leaving in + guard leaving else { return } + router.settingsOpen = false + router.profileOpen = false + } .task(id: router.tab) { await refreshPending() } .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await refreshPending(); await session.refreshStatus() } } @@ -103,27 +109,30 @@ struct MainTabView: View { Task { try? await UNUserNotificationCenter.current().setBadgeCount(count) } } .task { - // The server knows the language the app shows (titles and descriptions follow it). + // The server knows the language the app shows (titles and descriptions follow it) and the region (release + // dates, box office): no region yet, or the one the app took from the iPhone earlier, becomes the iPhone's + // current one; a region the person picked is kept (see `AutoRegion`). One call, so the tabs below are + // rebuilt once and no second update is cancelled by that rebuild. let code = Bundle.main.preferredLocalizations.first ?? "en" - if !profile.language.lowercased().hasPrefix(code), !(profile.language.isEmpty && code == "en"), - let updated = try? await api.updatePreferences(language: code) { - session.update(profile: updated) - } - } - .task { - // No region yet: the iPhone's one, which the person can change in Settings. - if profile.region.isEmpty, let code = Locale.current.region?.identifier, code.count == 2, - let updated = try? await api.updatePreferences(region: code.uppercased()) { - session.update(profile: updated) - } + let language = !profile.language.lowercased().hasPrefix(code) && !(profile.language.isEmpty && code == "en") ? code : nil + let region = AutoRegion.pending(profileRegion: profile.region, deviceRegion: Locale.current.region?.identifier, accountID: account.id) + guard language != nil || region != nil, let updated = try? await api.updatePreferences(language: language, region: region) else { return } + if let region { AutoRegion.record(region, for: account.id) } + session.update(profile: updated) } .task { // People who manage requests see their pending count on the icon, so they are asked at once (alerts, sounds and the badge together). - if profile.can(.manageRequests) { _ = try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) } + // Never while signing out: the system prompt would then show over Connect. + if profile.can(.manageRequests), !session.signingOut, case .ready = session.phase, !Task.isCancelled { + _ = try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) + } + guard !Task.isCancelled, !session.signingOut else { return } await PushManager.shared.attach(account: account, pushAvailable: session.serverStatus?.pushAvailable ?? false) } - // A new language or account rebuilds the tabs, so every list is fetched again in that language. - .id("\(account.id)|\(settings.language ?? "")|\(profile.region)") + // A new language, region or account rebuilds the tabs, so every list is fetched again with them. The profile + // language counts too: it is updated above after the iPhone's language changed, and Discover was loaded + // with the old one in the meantime. + .id("\(account.id)|\(settings.language ?? "")|\(profile.language)|\(profile.region)") } /// The four tabs inside the system bar (iPhone, narrow windows), or with our own bar: a sidebar on a diff --git a/ios/Tipsarr/Tipsarr/Core/Auth/SessionManager.swift b/ios/Tipsarr/Tipsarr/Core/Auth/SessionManager.swift index cb2c4f0a..3fa34f80 100644 --- a/ios/Tipsarr/Tipsarr/Core/Auth/SessionManager.swift +++ b/ios/Tipsarr/Tipsarr/Core/Auth/SessionManager.swift @@ -118,8 +118,16 @@ final class SessionManager { } } - /// Removes the account from the phone; the next one (or Connect) takes over. + /// True from the sign-out confirmation until the account is gone: the tabs close their sheets first. + private(set) var signingOut = false + + /// Removes the account from the device; the next one (or Connect) takes over. func signOut(_ account: Account) async { + // The Settings (or Profile) sheet is closed before the tabs go away: on iPad a sheet whose tabs were replaced + // under it stayed on screen over Connect, showing zero accounts. + signingOut = true + defer { signingOut = false } + try? await Task.sleep(for: .milliseconds(400)) CredentialStore.delete(accountID: account.id) await PushManager.shared.detach(account: account) await TipsarrAPI(serverURL: account.serverURL, token: account.token).logout() diff --git a/ios/Tipsarr/Tipsarr/Core/Push/PushManager.swift b/ios/Tipsarr/Tipsarr/Core/Push/PushManager.swift index c2672b97..5b3382ee 100644 --- a/ios/Tipsarr/Tipsarr/Core/Push/PushManager.swift +++ b/ios/Tipsarr/Tipsarr/Core/Push/PushManager.swift @@ -64,7 +64,8 @@ final class PushManager { /// Before sign-out: stop alerts for this account on this device. func detach(account: Account) async { _ = try? await TipsarrAPI(serverURL: account.serverURL, token: account.token).unregisterDevice() - if self.account?.id == account.id { registered = false } + // Signed out: nothing may ask for permission or register for this account any more. + if self.account?.id == account.id { registered = false; self.account = nil } } func didRegister(deviceToken data: Data) { diff --git a/ios/Tipsarr/Tipsarr/Core/Support/AppSettings.swift b/ios/Tipsarr/Tipsarr/Core/Support/AppSettings.swift index 87f44fac..bd314ebb 100644 --- a/ios/Tipsarr/Tipsarr/Core/Support/AppSettings.swift +++ b/ios/Tipsarr/Tipsarr/Core/Support/AppSettings.swift @@ -69,3 +69,38 @@ final class AppSettings { language = nil } } + +/// The region the app saved by itself from the iPhone's, per account. While the profile still has that region, a new +/// iPhone region (after a language and region change) replaces it at the next launch. A region the person picked +/// is never replaced: one picked in Settings here is recorded as "" (picked), one picked on the web app or Android +/// differs from the recorded region. +enum AutoRegion { + private static func key(_ accountID: String) -> String { "autoRegion.\(accountID)" } + + /// nil: nothing recorded yet. "": the person picked the region in Settings. Else the region the app saved. + static func saved(for accountID: String) -> String? { + UserDefaults.standard.string(forKey: key(accountID)) + } + + static func record(_ region: String, for accountID: String) { + UserDefaults.standard.set(region, forKey: key(accountID)) + } + + /// The person picked a region: the app stops following the iPhone's. + static func markPicked(for accountID: String) { record("", for: accountID) } + + /// The iPhone's region to save on the profile, or nil to keep the profile's. + static func pending(profileRegion: String, deviceRegion: String?, accountID: String) -> String? { + guard let device = deviceRegion?.uppercased(), device.count == 2, device.allSatisfy(\.isLetter) else { return nil } + let current = profileRegion.uppercased() + if current.isEmpty { return device } + let saved = saved(for: accountID) + // Nothing recorded (signed in with an older build): a region equal to the iPhone's is taken as the app's own. + if saved == nil, current == device { + record(current, for: accountID) + return nil + } + guard current != device, let saved, saved.uppercased() == current else { return nil } + return device + } +} diff --git a/ios/Tipsarr/Tipsarr/Core/Support/Plural.swift b/ios/Tipsarr/Tipsarr/Core/Support/Plural.swift index 5095f498..be2fc9f8 100644 --- a/ios/Tipsarr/Tipsarr/Core/Support/Plural.swift +++ b/ios/Tipsarr/Tipsarr/Core/Support/Plural.swift @@ -33,6 +33,14 @@ enum L10n { return String(format: format, arguments: args.map { $0 as NSString }) } + /// TMDB department names ("Acting", "Directing") are English whatever the request language: the translated + /// name for the ones the catalog knows (`dept.`, shared with the web app), else TMDB's text. + static func department(_ raw: String) -> String { + let key = "dept.\(raw)" + let text = AppLanguage.bundle.localizedString(forKey: key, value: nil, table: nil) + return text == key ? raw : text + } + /// Localized string for a dynamic key with `%@` / `%1$@` arguments (SwiftUI cannot do this with a runtime key). /// Arguments are turned into text first: a number passed as a C vararg to `%@` crashes. static func string(_ key: String, _ args: CustomStringConvertible...) -> String { diff --git a/ios/Tipsarr/Tipsarr/DesignSystem/SheetChrome.swift b/ios/Tipsarr/Tipsarr/DesignSystem/SheetChrome.swift index d0f17678..58616168 100644 --- a/ios/Tipsarr/Tipsarr/DesignSystem/SheetChrome.swift +++ b/ios/Tipsarr/Tipsarr/DesignSystem/SheetChrome.swift @@ -24,18 +24,28 @@ extension View { extension View { /// A bottom sheet as tall as its content (`contentHeight`, measured by the sheet), up to `maxHeight`; drag up for - /// the full height. iOS 26 adds the bottom safe area to a custom height on a floating sheet (empty space under the - /// last button), so it is taken off here. - func fittedSheetDetents(_ contentHeight: CGFloat, maxHeight: CGFloat = 640) -> some View { - let height = contentHeight > 0 ? max(contentHeight - Self.windowBottomInset, 1) : 0 - return presentationDetents([.height(min(height, maxHeight)), .large]) + /// the full height. `pinnedFooter`: the content ends with a `safeAreaInset` bar that already stays clear of the + /// sheet's bottom safe area, so none of it is taken off. + func fittedSheetDetents(_ contentHeight: CGFloat, maxHeight: CGFloat = 640, pinnedFooter: Bool = false) -> some View { + modifier(FittedSheetDetents(contentHeight: contentHeight, maxHeight: maxHeight, pinnedFooter: pinnedFooter)) } +} + +/// iOS 26 adds the sheet's bottom safe area to a custom height on a floating iPhone sheet (empty space under the last +/// button of a scrolling form), so it is taken off. The inset is the sheet's own, not the window's: an iPad sheet floats +/// above the home indicator without one and gets exactly the height asked for (taking the window's inset off cut its +/// last line). +private struct FittedSheetDetents: ViewModifier { + let contentHeight: CGFloat + let maxHeight: CGFloat + let pinnedFooter: Bool + @State private var bottomInset: CGFloat = 0 - @MainActor private static var windowBottomInset: CGFloat { - UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap(\.windows) - .first(where: \.isKeyWindow)?.safeAreaInsets.bottom ?? 0 + func body(content: Content) -> some View { + let height = contentHeight > 0 ? max(contentHeight - (pinnedFooter ? 0 : bottomInset), 1) : 0 + content + .onGeometryChange(for: CGFloat.self, of: { $0.safeAreaInsets.bottom }) { bottomInset = $0 } + .presentationDetents([.height(min(height, maxHeight)), .large]) } } diff --git a/ios/Tipsarr/Tipsarr/Features/Admin/PosterCarousel.swift b/ios/Tipsarr/Tipsarr/Features/Admin/PosterCarousel.swift index 72d0164b..790e63c4 100644 --- a/ios/Tipsarr/Tipsarr/Features/Admin/PosterCarousel.swift +++ b/ios/Tipsarr/Tipsarr/Features/Admin/PosterCarousel.swift @@ -60,7 +60,10 @@ struct PosterCarousel: View { .background(Tokens.palette.muted) .clipShape(.rect(cornerRadius: Tokens.Radius.md)) Text(verbatim: row.title).font(.subheadline.weight(.medium)).lineLimit(1) - Text(verbatim: row.note).font(.caption).foregroundStyle(Tokens.palette.mutedFg).lineLimit(1) + // Two lines: "6,9 h · 8 épisodes regardés" does not fit a poster's width on one. Space for both is kept so the cards line up. + Text(verbatim: row.note).font(.caption).foregroundStyle(Tokens.palette.mutedFg) + .lineLimit(2, reservesSpace: true) + .fixedSize(horizontal: false, vertical: true) } .frame(width: posterWidth) .accessibilityElement(children: .ignore) @@ -124,9 +127,9 @@ enum StatsFormat { return value >= 100 ? "\(Int(value.rounded())) \(unit)" : "\(value.formatted(.number.precision(.fractionLength(1)))) \(unit)" } - /// "12.5 h · 8 episodes watched" for shows, "3.2 h · 2 plays" for movies. + /// "12.5 h · 8 episodes watched" for shows, "3.2 h · 1 play" for movies. static func note(_ item: StatsTop) -> String { - let count = item.type == .tv ? L10n.string("stats.n_episodes", String(item.plays)) : L10n.string("stats.n_plays", String(item.plays)) + let count = Plural.text(item.type == .tv ? "m.stats.n_episodes" : "m.stats.n_plays", count: item.plays) return "\(hours(item.hours)) · \(count)" } } diff --git a/ios/Tipsarr/Tipsarr/Features/Admin/StatsView.swift b/ios/Tipsarr/Tipsarr/Features/Admin/StatsView.swift index 97e9f45f..5682418a 100644 --- a/ios/Tipsarr/Tipsarr/Features/Admin/StatsView.swift +++ b/ios/Tipsarr/Tipsarr/Features/Admin/StatsView.swift @@ -114,11 +114,12 @@ struct StatsView: View { Grid(horizontalSpacing: Tokens.Spacing.md, verticalSpacing: Tokens.Spacing.md) { GridRow { tile(r.exact ? "stats.tile_hours" : "stats.tile_hours_est", StatsFormat.hours(r.hours), L10n.string("stats.unit_hours_long")) - tile("stats.tile_titles", String(r.titles), L10n.string("stats.tile_titles_sub", String(r.movies), String(r.shows))) + tile("stats.tile_titles", String(r.titles), "\(Plural.text("m.stats.n_movies", count: r.movies)) · \(Plural.text("m.stats.n_shows", count: r.shows))") } GridRow { tile("stats.tile_plays", String(r.plays), r.genres.first.map { L10n.string("stats.tile_plays_sub", $0.name) }) - tile("stats.tile_requests", String(r.requestsMade), L10n.string("stats.tile_requests_sub", String(r.requestsAvailable), String(r.requestsDeclined))) + tile("stats.tile_requests", String(r.requestsMade), + "\(Plural.text("m.stats.n_available", count: r.requestsAvailable)) · \(Plural.text("m.stats.n_declined", count: r.requestsDeclined))") } } } @@ -155,7 +156,7 @@ struct StatsView: View { BarMark(x: .value("v", byHours && item.hours > 0 ? item.hours : Double(item.titles)), y: .value("Name", item.name)) .foregroundStyle(Tokens.palette.primary) .annotation(position: .trailing) { - Text(verbatim: byHours && item.hours > 0 ? StatsFormat.hours(item.hours) : L10n.string("stats.n_titles", String(item.titles))) + Text(verbatim: byHours && item.hours > 0 ? StatsFormat.hours(item.hours) : Plural.text("m.stats.n_titles", count: item.titles)) .font(.caption2).foregroundStyle(Tokens.palette.mutedFg) } } @@ -203,7 +204,7 @@ struct StatsView: View { Text(who == nil || !isAdmin ? "stats.unwatched_mine" : "stats.unwatched_others") .font(.footnote).foregroundStyle(Tokens.palette.mutedFg) if unwatched.total > unwatched.items.count { - Text(verbatim: L10n.string("stats.unwatched_more", String(unwatched.total - unwatched.items.count))) + Text(verbatim: Plural.text("m.stats.unwatched_more", count: unwatched.total - unwatched.items.count)) .font(.footnote).foregroundStyle(Tokens.palette.mutedFg) } } @@ -220,7 +221,7 @@ struct StatsView: View { Text("stats.never_none").font(.subheadline).foregroundStyle(Tokens.palette.mutedFg) } else { PosterCarousel(title: "stats.never_title", titleKey: "stats.never_title", rows: neverWatched.items.map(\.row)) - Text(verbatim: L10n.string("stats.never_hint", String(neverWatched.total))).font(.footnote).foregroundStyle(Tokens.palette.mutedFg) + Text(verbatim: Plural.text("m.stats.never_hint", count: neverWatched.total)).font(.footnote).foregroundStyle(Tokens.palette.mutedFg) } } } diff --git a/ios/Tipsarr/Tipsarr/Features/Detail/RequestSheet.swift b/ios/Tipsarr/Tipsarr/Features/Detail/RequestSheet.swift index 83efa0d5..97c8b203 100644 --- a/ios/Tipsarr/Tipsarr/Features/Detail/RequestSheet.swift +++ b/ios/Tipsarr/Tipsarr/Features/Detail/RequestSheet.swift @@ -10,7 +10,10 @@ struct RequestSheet: View { @State private var profileID: Int? @State private var folder: String? @State private var errorText: String? - @State private var contentHeight: CGFloat = 560 + @State private var formHeight: CGFloat = 460 + @State private var actionsHeight: CGFloat = 100 + /// The sheet's bottom safe area (iPhone home indicator): the buttons need less padding above it. + @State private var bottomInset: CGFloat = 0 private var detail: MediaDetail? { model.detail } @@ -37,28 +40,41 @@ struct RequestSheet: View { .font(.footnote) .foregroundStyle(Tokens.palette.mutedFg) if let errorText { Banner(kind: .error, title: LText.verbatim(errorText)) } - VStack(spacing: Tokens.Spacing.sm) { - Button { Task { await submit() } } label: { - Label { - if model.busy { Text("media.requesting") } else { Text("media.request") } - } icon: { Image(systemName: "plus") } - } - .buttonStyle(.tipsarr(.primary, fullWidth: true)) - .disabled(model.busy || (isTV && selectedSeasons.isEmpty)) - Button("common.cancel") { dismiss() } - .buttonStyle(.tipsarr(.tonal, fullWidth: true)) - } } .padding(Tokens.Spacing.xl) .padding(.top, Tokens.Spacing.md) - .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { contentHeight = $0 } + .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { formHeight = $0 } } .scrollBounceBehavior(.basedOnSize) + // Pinned under the form: whatever height the sheet gets (a shorter detent, a long list of seasons, an iPad + // window), Request and Cancel stay on screen and the form scrolls above them. + .safeAreaInset(edge: .bottom, spacing: 0) { + actions + .padding(.horizontal, Tokens.Spacing.xl) + .padding(.bottom, bottomInset > 0 ? Tokens.Spacing.sm : Tokens.Spacing.xl) + .background(Tokens.palette.bg) + .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { actionsHeight = $0 } + } + .onGeometryChange(for: CGFloat.self, of: { $0.safeAreaInsets.bottom }) { bottomInset = $0 } // Only as tall as the form needs (a movie), up to the full height (a long list of seasons). - .fittedSheetDetents(contentHeight) + .fittedSheetDetents(formHeight + actionsHeight, pinnedFooter: true) .onAppear(perform: setDefaults) } + private var actions: some View { + VStack(spacing: Tokens.Spacing.sm) { + Button { Task { await submit() } } label: { + Label { + if model.busy { Text("media.requesting") } else { Text("media.request") } + } icon: { Image(systemName: "plus") } + } + .buttonStyle(.tipsarr(.primary, fullWidth: true)) + .disabled(model.busy || (isTV && selectedSeasons.isEmpty)) + Button("common.cancel") { dismiss() } + .buttonStyle(.tipsarr(.tonal, fullWidth: true)) + } + } + private var header: some View { HStack(spacing: Tokens.Spacing.md) { RemoteImage(path: detail?.posterPath, size: .w185) { diff --git a/ios/Tipsarr/Tipsarr/Features/Person/PersonScreen.swift b/ios/Tipsarr/Tipsarr/Features/Person/PersonScreen.swift index 561f5829..2c95dcbc 100644 --- a/ios/Tipsarr/Tipsarr/Features/Person/PersonScreen.swift +++ b/ios/Tipsarr/Tipsarr/Features/Person/PersonScreen.swift @@ -41,7 +41,7 @@ struct PersonScreen: View { VStack(alignment: .leading, spacing: Tokens.Spacing.xs) { Text(verbatim: person.name).font(.title2.weight(.bold)).accessibilityAddTraits(.isHeader) if let department = person.department, !department.isEmpty { - Text(verbatim: department).font(.subheadline).foregroundStyle(Tokens.palette.mutedFg) + Text(verbatim: L10n.department(department)).font(.subheadline).foregroundStyle(Tokens.palette.mutedFg) } if let born = person.birthday.flatMap(Self.date) { Text(verbatim: L10n.string("m.person.born", born)).font(.footnote).foregroundStyle(Tokens.palette.mutedFg) diff --git a/ios/Tipsarr/Tipsarr/Features/Search/SearchView.swift b/ios/Tipsarr/Tipsarr/Features/Search/SearchView.swift index 61509237..029f5bed 100644 --- a/ios/Tipsarr/Tipsarr/Features/Search/SearchView.swift +++ b/ios/Tipsarr/Tipsarr/Features/Search/SearchView.swift @@ -62,8 +62,11 @@ struct SearchView: View { private var splitBody: some View { NavigationSplitView(columnVisibility: .constant(.all)) { searchable(pane, placement: .sidebar) - .navigationSplitViewColumnWidth(min: 340, ideal: 400, max: 460) .mediaDestinations() + .toolbar(removing: .sidebarToggle) + // Last: put before `.navigationDestination` it was ignored, the column stayed at the 320 pt system + // width and the scope segments were cut ("Perso…"). + .navigationSplitViewColumnWidth(min: 360, ideal: 400, max: 460) } detail: { NavigationStack { if let selection { @@ -346,7 +349,7 @@ struct PersonBubble: View { PersonPhoto(path: person.profilePath).frame(width: 72, height: 72) Text(verbatim: person.name).font(.caption.weight(.medium)).lineLimit(1) if let department = person.department, !department.isEmpty { - Text(verbatim: department).font(.caption2).foregroundStyle(Tokens.palette.mutedFg).lineLimit(1) + Text(verbatim: L10n.department(department)).font(.caption2).foregroundStyle(Tokens.palette.mutedFg).lineLimit(1) } } .frame(width: 88) @@ -363,7 +366,7 @@ struct PersonRow: View { VStack(alignment: .leading, spacing: 2) { Text(verbatim: person.name).font(.body.weight(.medium)) if let department = person.department, !department.isEmpty { - Text(verbatim: department).font(.footnote).foregroundStyle(Tokens.palette.mutedFg) + Text(verbatim: L10n.department(department)).font(.footnote).foregroundStyle(Tokens.palette.mutedFg) } } Spacer() diff --git a/ios/Tipsarr/Tipsarr/Features/Settings/SettingsView.swift b/ios/Tipsarr/Tipsarr/Features/Settings/SettingsView.swift index d35e0968..dfca5832 100644 --- a/ios/Tipsarr/Tipsarr/Features/Settings/SettingsView.swift +++ b/ios/Tipsarr/Tipsarr/Features/Settings/SettingsView.swift @@ -255,6 +255,8 @@ struct RegionPickerScreen: View { private func pick(_ code: String) { if popsOnPick { dismiss() } + // A region picked here (even the current one) is the person's: the app no longer follows the iPhone's. + if let account = session.accounts.active { AutoRegion.markPicked(for: account.id) } guard let api = context?.api, code != selection else { return } Task { await savePreference(session: session, toast: toast) { try await api.updatePreferences(region: code) } } } diff --git a/ios/Tipsarr/Tipsarr/Localizable.xcstrings b/ios/Tipsarr/Tipsarr/Localizable.xcstrings index a52de2e6..9eaf2199 100644 --- a/ios/Tipsarr/Tipsarr/Localizable.xcstrings +++ b/ios/Tipsarr/Tipsarr/Localizable.xcstrings @@ -1574,6 +1574,22 @@ } } }, + "error.address_refused": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This address is not allowed (loopback or link-local)." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette adresse n’est pas autorisée (loopback ou link-local)." + } + } + } + }, "error.admin_only": { "localizations": { "en": { @@ -4133,6 +4149,23 @@ } } }, + "login.demo_apps": { + "comment": "placeholders: server, admin, user, password", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Using the mobile apps? Server %1$@, user %2$@ (admin) or %3$@, password %4$@." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tu utilises les applis mobiles ? Serveur : %1$@, identifiant %2$@ (admin) ou %3$@, mot de passe %4$@." + } + } + } + }, "login.demo_desc": { "localizations": { "en": { @@ -4506,13 +4539,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Signing out removes an account from this iPhone." + "value": "Signing out removes an account from this device." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Se déconnecter retire un compte de cet iPhone." + "value": "Se déconnecter retire un compte de cet appareil." } } } @@ -7090,13 +7123,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Your password is kept in the iPhone’s Keychain, protected by Face ID or your passcode. It is only used to sign you back in when your session ends." + "value": "Your password is kept in the device’s Keychain, protected by Face ID or your passcode. It is only used to sign you back in when your session ends." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Ton mot de passe est conservé dans le trousseau de l’iPhone, protégé par Face ID ou ton code. Il sert uniquement à te reconnecter quand ta session se termine." + "value": "Ton mot de passe est conservé dans le trousseau de l’appareil, protégé par Face ID ou ton code. Il sert uniquement à te reconnecter quand ta session se termine." } } } @@ -7122,13 +7155,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Default (iPhone language)" + "value": "Default (device language)" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Par défaut (langue de l’iPhone)" + "value": "Par défaut (langue de l’appareil)" } } } @@ -7426,13 +7459,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Sign out of Tipsarr on this iPhone?" + "value": "Sign out of Tipsarr on this device?" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Se déconnecter de Tipsarr sur cet iPhone ?" + "value": "Se déconnecter de Tipsarr sur cet appareil ?" } } } @@ -7474,13 +7507,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Follows your iPhone" + "value": "Follows your device" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Suit ton iPhone" + "value": "Suit ton appareil" } } } @@ -7501,6 +7534,142 @@ } } }, + "m.stats.n_available": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ available | %1$@ available" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ disponible | %1$@ disponibles" + } + } + } + }, + "m.stats.n_declined": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ declined | %1$@ declined" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ refusée | %1$@ refusées" + } + } + } + }, + "m.stats.n_episodes": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ episode watched | %1$@ episodes watched" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ épisode regardé | %1$@ épisodes regardés" + } + } + } + }, + "m.stats.n_movies": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ movie | %1$@ movies" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ film | %1$@ films" + } + } + } + }, + "m.stats.n_plays": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ play | %1$@ plays" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ lecture | %1$@ lectures" + } + } + } + }, + "m.stats.n_shows": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ TV show | %1$@ TV shows" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ série | %1$@ séries" + } + } + } + }, + "m.stats.n_titles": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ title | %1$@ titles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ titre | %1$@ titres" + } + } + } + }, + "m.stats.never_hint": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ title in the library that nobody ever played, oldest first (admins only). | %1$@ titles in the library that nobody ever played, oldest first (admins only)." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ titre de la bibliothèque que personne n’a jamais lu, les plus anciens d’abord (admins uniquement). | %1$@ titres de la bibliothèque que personne n’a jamais lus, les plus anciens d’abord (admins uniquement)." + } + } + } + }, "m.stats.open": { "localizations": { "en": { @@ -7517,6 +7686,23 @@ } } }, + "m.stats.unwatched_more": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "and %1$@ more | and %1$@ more" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "et %1$@ autre | et %1$@ autres" + } + } + } + }, "m.sync.counts": { "localizations": { "en": { @@ -10197,6 +10383,22 @@ } } }, + "profile.picture_too_big": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That picture is too large: at most 4096 × 4096 pixels." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette image est trop grande : 4096 × 4096 pixels au maximum." + } + } + } + }, "profile.picture_too_large": { "localizations": { "en": { diff --git a/locales/en/mobile.json b/locales/en/mobile.json index 8987f190..a1aec95e 100644 --- a/locales/en/mobile.json +++ b/locales/en/mobile.json @@ -29,17 +29,17 @@ "m.accounts.add": "Add account", "m.accounts.manage": "Manage accounts", "m.accounts.switching": "Switching to {name}…", - "m.accounts.footer_signout": "Signing out removes an account from this iPhone.", + "m.accounts.footer_signout": "Signing out removes an account from this device.", "m.accounts.intro": "Each account keeps its own sign-in.", "m.accounts.server": "Server", "m.accounts.current_server": "Current server", "m.accounts.other_server": "Another server…", "m.faceid.usage": "Tipsarr uses Face ID to unlock the app.", "m.settings.theme_system": "System", - "m.settings.theme_system_hint": "Follows your iPhone", + "m.settings.theme_system_hint": "Follows your device", "m.settings.theme_footer": "Choose how Tipsarr looks. System follows the appearance set in iOS.", - "m.settings.language_default": "Default (iPhone language)", - "m.settings.sign_out_confirm": "Sign out of Tipsarr on this iPhone?", + "m.settings.language_default": "Default (device language)", + "m.settings.sign_out_confirm": "Sign out of Tipsarr on this device?", "m.picture.library": "Choose from library", "m.picture.footer": "Pictures are shared with other people on this server. Max 4 MB.", "m.detail.open_jellyfin": "Open in Jellyfin", @@ -125,7 +125,7 @@ "m.session.use_password": "Use my password", "m.session.signing_in_faceid": "Signing in…", "m.settings.faceid_signin": "Sign in with Face ID", - "m.settings.faceid_signin_footer": "Your password is kept in the iPhone’s Keychain, protected by Face ID or your passcode. It is only used to sign you back in when your session ends.", + "m.settings.faceid_signin_footer": "Your password is kept in the device’s Keychain, protected by Face ID or your passcode. It is only used to sign you back in when your session ends.", "m.detail.share": "Share", "m.admin.title": "Administration", "m.admin.sync": "Sync & jobs", @@ -185,6 +185,15 @@ "m.accounts.title_one": "Account", "m.settings.hidden": "Hidden content", "m.stats.open": "Statistics", + "m.stats.n_plays": "{count} play | {count} plays", + "m.stats.n_episodes": "{count} episode watched | {count} episodes watched", + "m.stats.n_titles": "{count} title | {count} titles", + "m.stats.n_movies": "{count} movie | {count} movies", + "m.stats.n_shows": "{count} TV show | {count} TV shows", + "m.stats.n_available": "{count} available | {count} available", + "m.stats.n_declined": "{count} declined | {count} declined", + "m.stats.unwatched_more": "and {count} more | and {count} more", + "m.stats.never_hint": "{count} title in the library that nobody ever played, oldest first (admins only). | {count} titles in the library that nobody ever played, oldest first (admins only).", "m.library.under": "under {duration}", "m.library.close": "Close", "m.library.genre_all": "All selected", diff --git a/locales/fr/mobile.json b/locales/fr/mobile.json index 8dd74d31..42f41317 100644 --- a/locales/fr/mobile.json +++ b/locales/fr/mobile.json @@ -29,17 +29,17 @@ "m.accounts.add": "Ajouter un compte", "m.accounts.manage": "Gérer les comptes", "m.accounts.switching": "Passage à {name}…", - "m.accounts.footer_signout": "Se déconnecter retire un compte de cet iPhone.", + "m.accounts.footer_signout": "Se déconnecter retire un compte de cet appareil.", "m.accounts.intro": "Chaque compte garde sa connexion.", "m.accounts.server": "Serveur", "m.accounts.current_server": "Serveur actuel", "m.accounts.other_server": "Un autre serveur…", "m.faceid.usage": "Tipsarr utilise Face ID pour déverrouiller l’app.", "m.settings.theme_system": "Système", - "m.settings.theme_system_hint": "Suit ton iPhone", + "m.settings.theme_system_hint": "Suit ton appareil", "m.settings.theme_footer": "Choisis l’apparence de Tipsarr. Système suit l’apparence réglée dans iOS.", - "m.settings.language_default": "Par défaut (langue de l’iPhone)", - "m.settings.sign_out_confirm": "Se déconnecter de Tipsarr sur cet iPhone ?", + "m.settings.language_default": "Par défaut (langue de l’appareil)", + "m.settings.sign_out_confirm": "Se déconnecter de Tipsarr sur cet appareil ?", "m.picture.library": "Choisir dans la photothèque", "m.picture.footer": "Les photos sont visibles par les autres personnes de ce serveur. 4 Mo maximum.", "m.detail.open_jellyfin": "Ouvrir dans Jellyfin", @@ -125,7 +125,7 @@ "m.session.use_password": "Utiliser mon mot de passe", "m.session.signing_in_faceid": "Connexion…", "m.settings.faceid_signin": "Se connecter avec Face ID", - "m.settings.faceid_signin_footer": "Ton mot de passe est conservé dans le trousseau de l’iPhone, protégé par Face ID ou ton code. Il sert uniquement à te reconnecter quand ta session se termine.", + "m.settings.faceid_signin_footer": "Ton mot de passe est conservé dans le trousseau de l’appareil, protégé par Face ID ou ton code. Il sert uniquement à te reconnecter quand ta session se termine.", "m.detail.share": "Partager", "m.admin.title": "Administration", "m.admin.sync": "Synchro et tâches", @@ -185,6 +185,15 @@ "m.accounts.title_one": "Compte", "m.settings.hidden": "Contenu masqué", "m.stats.open": "Statistiques", + "m.stats.n_plays": "{count} lecture | {count} lectures", + "m.stats.n_episodes": "{count} épisode regardé | {count} épisodes regardés", + "m.stats.n_titles": "{count} titre | {count} titres", + "m.stats.n_movies": "{count} film | {count} films", + "m.stats.n_shows": "{count} série | {count} séries", + "m.stats.n_available": "{count} disponible | {count} disponibles", + "m.stats.n_declined": "{count} refusée | {count} refusées", + "m.stats.unwatched_more": "et {count} autre | et {count} autres", + "m.stats.never_hint": "{count} titre de la bibliothèque que personne n’a jamais lu, les plus anciens d’abord (admins uniquement). | {count} titres de la bibliothèque que personne n’a jamais lus, les plus anciens d’abord (admins uniquement).", "m.library.under": "moins de {duration}", "m.library.close": "Fermer", "m.library.genre_all": "Tous les genres",