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",