Let the words fit the things they describe

tvOS UX campaign polish (TV1, TV3, TV5, TV6):
- Invidious sign-in asks for a Username, which is what it verifies;
  the wire field is still called email, noted where it is sent
- Video counts pluralize ("1 video"), via catalog plural variations;
  the two-argument count-and-duration key uses fully positional
  substitutions, since a mixed format made CFRetain fault on the
  integer argument (TV10)
- Character counters render their limits verbatim ("0/1000"), here
  and in the preset-name and bookmark-note counters with the same bug
- Deleting a playlist, local or remote, names the playlist the way
  removing an account already named the account
This commit is contained in:
Arkadiusz Fal
2026-09-10 20:34:46 +02:00
parent b67b3472f6
commit 011ffee53c
8 changed files with 118 additions and 44 deletions

View File

@@ -5309,9 +5309,21 @@
"comment" : "Video count label for playlist row",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld videos"
"variations" : {
"plural" : {
"one" : {
"stringUnit" : {
"state" : "translated",
"value" : "1 video"
}
},
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld videos"
}
}
}
}
}
}
@@ -5594,24 +5606,13 @@
}
}
},
"login.email" : {
"comment" : "Email field label in login form",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Email or Username"
}
}
}
},
"login.error.invalidCredentials" : {
"comment" : "Error message for invalid login credentials",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Invalid email or password"
"value" : "Invalid username or password"
}
}
}
@@ -8430,6 +8431,17 @@
}
}
},
"playlist.delete.confirm.named %@" : {
"comment" : "Delete playlist confirmation with the playlist name",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Are you sure you want to delete \"%@\"?"
}
}
}
},
"playlist.description" : {
"comment" : "Description field label",
"localizations" : {
@@ -8690,6 +8702,17 @@
}
}
},
"playlist.remote.delete.confirm.named %@" : {
"comment" : "Delete server-side playlist confirmation with the playlist name",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Delete \"%@\" from your account?"
}
}
}
},
"playlist.remote.delete.error" : {
"comment" : "Deleting a playlist from the server failed",
"localizations" : {
@@ -8814,9 +8837,21 @@
"playlist.videoCount %lld" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld videos"
"variations" : {
"plural" : {
"one" : {
"stringUnit" : {
"state" : "translated",
"value" : "1 video"
}
},
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld videos"
}
}
}
}
}
}
@@ -8826,7 +8861,29 @@
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld videos • %@"
"value" : "%1$#@videoCount@ • %2$@"
},
"substitutions" : {
"videoCount" : {
"argNum" : 1,
"formatSpecifier" : "lld",
"variations" : {
"plural" : {
"one" : {
"stringUnit" : {
"state" : "translated",
"value" : "1 video"
}
},
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%arg videos"
}
}
}
}
}
}
}
}

View File

@@ -58,6 +58,7 @@ struct PlaylistsListView: View {
private struct RemotePlaylistDeletion {
let account: Account
let playlistID: String
let title: String
}
// View options (persisted)
@@ -90,6 +91,17 @@ struct PlaylistsListView: View {
)
}
/// The delete confirmation, naming the playlist it is about.
///
/// A playlist the server gave no title for falls back to the unnamed
/// wording rather than asking about `""`.
private var remoteDeletionTitle: String {
guard let title = remoteDeletion?.title, !title.isEmpty else {
return String(localized: "playlist.remote.delete.confirm")
}
return String(localized: "playlist.remote.delete.confirm.named \(title)")
}
/// View options button lives on the leading edge on macOS, trailing elsewhere.
private var viewOptionsPlacement: ToolbarItemPlacement {
#if os(macOS)
@@ -300,7 +312,7 @@ struct PlaylistsListView: View {
// Taking a playlist off a server is a very different sentence from
// deleting a local one, which stays unconfirmed as it always was.
.confirmationDialog(
String(localized: "playlist.remote.delete.confirm"),
remoteDeletionTitle,
isPresented: Binding(
get: { remoteDeletion != nil },
set: { if !$0 { remoteDeletion = nil } }
@@ -683,7 +695,8 @@ struct PlaylistsListView: View {
Button(role: .destructive) {
remoteDeletion = RemotePlaylistDeletion(
account: account,
playlistID: playlist.id.playlistID
playlistID: playlist.id.playlistID,
title: playlist.title
)
} label: {
Label(String(localized: "playlist.delete"), systemImage: "trash")
@@ -737,7 +750,8 @@ struct PlaylistsListView: View {
reset()
remoteDeletion = RemotePlaylistDeletion(
account: account,
playlistID: playlist.id.playlistID
playlistID: playlist.id.playlistID,
title: playlist.title
)
}
]

View File

@@ -192,7 +192,7 @@ struct PlaylistFormSheet: View {
HStack {
Text(String(localized: "playlist.description.optional"))
Spacer()
Text("\(descriptionText.count)/\(maxDescriptionLength)")
Text(verbatim: "\(descriptionText.count)/\(maxDescriptionLength)")
.monospacedDigit()
}
.foregroundStyle(.secondary)
@@ -249,7 +249,7 @@ struct PlaylistFormSheet: View {
HStack {
Text(String(localized: "playlist.description.optional"))
Spacer()
Text("\(descriptionText.count)/\(maxDescriptionLength)")
Text(verbatim: "\(descriptionText.count)/\(maxDescriptionLength)")
.monospacedDigit()
}
.foregroundStyle(.secondary)

View File

@@ -181,10 +181,21 @@ struct UnifiedPlaylistDetailView: View {
/// Deleting a server-side playlist takes it off the server, which is a very
/// different sentence from deleting a local one.
///
/// Both name the playlist, and both fall back to the unnamed wording while
/// there is no name yet to say.
private var deleteConfirmationTitle: String {
isLocal
? String(localized: "playlist.delete.confirm")
: String(localized: "playlist.remote.delete.confirm")
let name = title.isEmpty ? cachedHeader?.title ?? "" : title
guard !name.isEmpty else {
return isLocal
? String(localized: "playlist.delete.confirm")
: String(localized: "playlist.remote.delete.confirm")
}
return isLocal
? String(localized: "playlist.delete.confirm.named \(name)")
: String(localized: "playlist.remote.delete.confirm.named \(name)")
}
private var navigationTitleText: String {

View File

@@ -165,9 +165,10 @@ struct InstanceLoginView: View {
/// What the first field is called on this backend.
///
/// Named per backend rather than genericised: Invidious signs in by email,
/// and calling that "Username" is how a user ends up typing the wrong thing.
/// Every case is spelled out so a backend that gains accounts —
/// Named per backend rather than genericised: every backend that has
/// accounts signs in by username — Invidious posts one in a form field it
/// still calls `email`, but an email is never what it wants. Every case is
/// spelled out so a backend that gains accounts —
/// `AccountCapabilities.supportsAccounts` is what decides that — arrives
/// here rather than falling through to somebody else's wording.
private var usernameFieldLabel: String {
@@ -175,7 +176,7 @@ struct InstanceLoginView: View {
case .piped:
return String(localized: "login.username")
case .invidious:
return String(localized: "login.email")
return String(localized: "login.username")
case .yatteeServer:
return String(localized: "login.username")
case .peertube:

View File

@@ -366,10 +366,10 @@ private struct LegacyAccountImportRow: View {
@ViewBuilder
private var credentialsFields: some View {
#if os(tvOS)
TVSettingsTextField(title: usernameLabel, text: $state.username)
TVSettingsTextField(title: String(localized: "login.username"), text: $state.username)
TVSettingsTextField(title: String(localized: "login.password"), text: $state.password, isSecure: true)
#else
TextField(usernameLabel, text: $state.username)
TextField(String(localized: "login.username"), text: $state.username)
.textContentType(.username)
#if os(iOS)
.textInputAutocapitalization(.never)
@@ -380,15 +380,6 @@ private struct LegacyAccountImportRow: View {
.textContentType(.password)
#endif
}
private var usernameLabel: String {
switch item.instanceType {
case .invidious:
return String(localized: "login.email")
default:
return String(localized: "login.username")
}
}
}
// MARK: - Source Row

View File

@@ -158,7 +158,7 @@ struct PresetEditorView: View {
.foregroundStyle(.red)
}
Spacer()
Text("\(trimmedName.count)/\(LayoutPreset.maxNameLength)")
Text(verbatim: "\(trimmedName.count)/\(LayoutPreset.maxNameLength)")
.foregroundStyle(
trimmedName.count > LayoutPreset.maxNameLength ? .red : .secondary
)

View File

@@ -1708,7 +1708,7 @@ struct VideoInfoView: View {
if bookmarkNote.count > 900 {
HStack {
Spacer()
Text("\(bookmarkNote.count)/1000")
Text(verbatim: "\(bookmarkNote.count)/1000")
.font(.caption)
.foregroundStyle(bookmarkNote.count > 1000 ? .red : .secondary)
.monospacedDigit()