From 09397cf3de9700d39fdbdc026016f8a65994b9d2 Mon Sep 17 00:00:00 2001 From: Josh Vincent Date: Fri, 6 Jun 2025 09:29:14 -0600 Subject: [PATCH] Improves chapter editing and adds bulk import Adds chapter locking functionality, allowing users to lock individual chapters or all chapters at once to prevent accidental edits. Implements time increment buttons to precisely adjust chapter start times. Introduces bulk chapter import functionality, allowing users to quickly add multiple chapters using a detected numbering pattern. Adds elapsed time display during chapter playback for better user feedback. Updates UI tooltips and icons for improved clarity and user experience. --- client/pages/audiobook/_id/chapters.vue | 385 ++++++++++++++++++++---- client/strings/en-us.json | 18 +- client/strings/es.json | 18 +- client/strings/fr.json | 20 +- 4 files changed, 378 insertions(+), 63 deletions(-) diff --git a/client/pages/audiobook/_id/chapters.vue b/client/pages/audiobook/_id/chapters.vue index a88407440..10840f25a 100644 --- a/client/pages/audiobook/_id/chapters.vue +++ b/client/pages/audiobook/_id/chapters.vue @@ -53,51 +53,102 @@
-
{{ $strings.LabelStart }}
-
{{ $strings.LabelTitle }}
+
+ + + +
+
{{ $strings.LabelStart }}
+
{{ $strings.LabelTitle }}
- +
+
+ + + + +
+ + +
+ + + + +
+
+
+ +
+
+
+ + + + + + + + + + + + +
{{ elapsedTime }}s
+ + + +
+
+ +
+
+
+
+
+ +
+
+ + + +
+
@@ -114,19 +165,15 @@
{{ $strings.LabelDuration }}
- +
+

{{ $secondsToTimestamp(Math.round(track.duration), false, true) }}

+
+ + @@ -209,6 +256,33 @@ + + +
+
+

{{ $strings.HeaderBulkChapterModal }}

+

{{ $strings.MessageBulkChapterPattern }}

+
+ {{ $strings.LabelDetectedPattern }} "{{ detectedPattern.before }}{{ detectedPattern.startingNumber }}{{ detectedPattern.after }}" +
+ {{ $strings.LabelNextChapters }} + "{{ detectedPattern.before }}{{ detectedPattern.startingNumber + 1 }}{{ detectedPattern.after }}", "{{ detectedPattern.before }}{{ detectedPattern.startingNumber + 2 }}{{ detectedPattern.after }}", etc. +
+
+ + +
+
+ {{ $strings.ButtonAddChapters }} + {{ $strings.ButtonCancel }} +
+
+
+
@@ -265,7 +339,17 @@ export default { removeBranding: false, showSecondInputs: false, audibleRegions: ['US', 'CA', 'UK', 'AU', 'FR', 'DE', 'JP', 'IT', 'IN', 'ES'], - hasChanges: false + hasChanges: false, + timeIncrementAmount: 1, + elapsedTime: 0, + playStartTime: null, + elapsedTimeInterval: null, + lockedChapters: new Set(), + lastSelectedLockIndex: null, + bulkChapterInput: '', + showBulkChapterModal: false, + bulkChapterCount: 1, + detectedPattern: null } }, computed: { @@ -304,6 +388,9 @@ export default { }, selectedChapterId() { return this.selectedChapter ? this.selectedChapter.id : null + }, + allChaptersLocked() { + return this.newChapters.length > 0 && this.newChapters.every((chapter) => this.lockedChapters.has(chapter.id)) } }, methods: { @@ -334,19 +421,27 @@ export default { const amount = Number(this.shiftAmount) - const lastChapter = this.newChapters[this.newChapters.length - 1] - if (lastChapter.start + amount > this.mediaDurationRounded) { - this.$toast.error(this.$strings.ToastChaptersInvalidShiftAmountLast) + // Check if any unlocked chapters would be affected negatively + const unlockedChapters = this.newChapters.filter((chap) => !this.lockedChapters.has(chap.id)) + + if (unlockedChapters.length === 0) { + this.$toast.warning(this.$strings.ToastChaptersAllLocked) return } - if (this.newChapters[1].start + amount <= 0) { - this.$toast.error(this.$strings.ToastChaptersInvalidShiftAmountStart) + if (unlockedChapters[0].id === 0 && unlockedChapters[0].end + amount <= 0) { + this.$toast.error(this.$strings.ToastChapterInvalidShiftAmount) return } for (let i = 0; i < this.newChapters.length; i++) { const chap = this.newChapters[i] + + // Skip locked chapters + if (this.lockedChapters.has(chap.id)) { + continue + } + chap.end = Math.min(chap.end + amount, this.mediaDuration) if (i > 0) { chap.start = Math.max(0, chap.start + amount) @@ -354,6 +449,96 @@ export default { } this.checkChapters() }, + incrementChapterTime(chapter, amount) { + // Don't allow incrementing first chapter below 0 + if (chapter.id === 0 && chapter.start + amount < 0) { + return + } + + // Don't allow incrementing beyond media duration + if (chapter.start + amount >= this.mediaDuration) { + return + } + + // Find the previous chapter to ensure we don't go below it + const previousChapter = this.newChapters[chapter.id - 1] + if (previousChapter && chapter.start + amount <= previousChapter.start) { + return + } + + // Find the next chapter to ensure we don't go above it + const nextChapter = this.newChapters[chapter.id + 1] + if (nextChapter && chapter.start + amount >= nextChapter.start) { + return + } + + chapter.start = Math.max(0, chapter.start + amount) + this.checkChapters() + }, + startElapsedTimeTracking() { + this.elapsedTime = 0 + this.playStartTime = Date.now() + this.elapsedTimeInterval = setInterval(() => { + this.elapsedTime = Math.floor((Date.now() - this.playStartTime) / 1000) + }, 100) // Update every 100ms for smooth display + }, + stopElapsedTimeTracking() { + if (this.elapsedTimeInterval) { + clearInterval(this.elapsedTimeInterval) + this.elapsedTimeInterval = null + } + this.elapsedTime = 0 + this.playStartTime = null + }, + toggleChapterLock(chapter, event) { + const chapterId = chapter.id + + // Handle shift-click for range selection + if (event.shiftKey && this.lastSelectedLockIndex !== null) { + const startIndex = Math.min(this.lastSelectedLockIndex, chapterId) + const endIndex = Math.max(this.lastSelectedLockIndex, chapterId) + + // Determine if we should lock or unlock based on the target chapter's current state + const shouldLock = !this.lockedChapters.has(chapterId) + + for (let i = startIndex; i <= endIndex; i++) { + if (shouldLock) { + this.lockedChapters.add(i) + } else { + this.lockedChapters.delete(i) + } + } + } else { + // Single chapter toggle + if (this.lockedChapters.has(chapterId)) { + this.lockedChapters.delete(chapterId) + } else { + this.lockedChapters.add(chapterId) + } + } + + this.lastSelectedLockIndex = chapterId + + // Force reactivity update + this.lockedChapters = new Set(this.lockedChapters) + }, + lockAllChapters() { + this.newChapters.forEach((chapter) => { + this.lockedChapters.add(chapter.id) + }) + this.lockedChapters = new Set(this.lockedChapters) + }, + unlockAllChapters() { + this.lockedChapters.clear() + this.lockedChapters = new Set(this.lockedChapters) + }, + toggleAllChaptersLock() { + if (this.allChaptersLocked) { + this.unlockAllChapters() + } else { + this.lockAllChapters() + } + }, editItem() { this.$store.commit('showEditModal', this.libraryItem) }, @@ -451,6 +636,7 @@ export default { console.log('Audio playing') this.isLoadingChapter = false this.isPlayingChapter = true + this.startElapsedTimeTracking() }) audioEl.addEventListener('ended', () => { console.log('Audio ended') @@ -473,6 +659,7 @@ export default { this.selectedChapter = null this.isPlayingChapter = false this.isLoadingChapter = false + this.stopElapsedTimeTracking() }, saveChapters() { this.checkChapters() @@ -679,6 +866,86 @@ export default { this.saving = false }) }, + handleBulkChapterAdd() { + const input = this.bulkChapterInput.trim() + if (!input) return + + // Check if input contains any numbers and extract pattern info + const numberMatch = input.match(/(\d+)/) + + if (numberMatch) { + // Extract the base pattern and number + const foundNumber = parseInt(numberMatch[1]) + const numberIndex = numberMatch.index + const beforeNumber = input.substring(0, numberIndex) + const afterNumber = input.substring(numberIndex + numberMatch[1].length) + + // Store pattern info for bulk creation + this.detectedPattern = { + before: beforeNumber, + after: afterNumber, + startingNumber: foundNumber + } + + // Show modal to ask for number of chapters + this.bulkChapterCount = 1 + this.showBulkChapterModal = true + } else { + // Add single chapter with the entered title + this.addSingleChapterFromInput(input) + } + }, + addSingleChapterFromInput(title) { + // Find the last chapter to determine where to add the new one + const lastChapter = this.newChapters[this.newChapters.length - 1] + const newStart = lastChapter ? lastChapter.end : 0 + const newEnd = Math.min(newStart + 300, this.mediaDuration) // Default 5 minutes or media duration + + const newChapter = { + id: this.newChapters.length, + start: newStart, + end: newEnd, + title: title + } + + this.newChapters.push(newChapter) + this.bulkChapterInput = '' + this.checkChapters() + }, + + addBulkChapters() { + const count = parseInt(this.bulkChapterCount) + if (!count || count < 1 || count > 150) { + this.$toast.error(this.$strings.ToastBulkChapterInvalidCount) + return + } + + const { before, after, startingNumber } = this.detectedPattern + const lastChapter = this.newChapters[this.newChapters.length - 1] + const baseStart = lastChapter ? lastChapter.end : 0 + const defaultDuration = 300 // 5 minutes per chapter + + // Add multiple chapters with the detected pattern + for (let i = 0; i < count; i++) { + const chapterNumber = startingNumber + i + const newStart = baseStart + i * defaultDuration + const newEnd = Math.min(newStart + defaultDuration, this.mediaDuration) + + const newChapter = { + id: this.newChapters.length, + start: newStart, + end: newEnd, + title: `${before}${chapterNumber}${after}` + } + + this.newChapters.push(newChapter) + } + + this.bulkChapterInput = '' + this.showBulkChapterModal = false + this.detectedPattern = null + this.checkChapters() + }, libraryItemUpdated(libraryItem) { if (libraryItem.id === this.libraryItem.id) { if (!!libraryItem.media.metadata.asin && this.mediaMetadata.asin !== libraryItem.media.metadata.asin) { diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 939eb9f4b..39726ff08 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -1103,5 +1103,21 @@ "ToastUserPasswordChangeSuccess": "Password changed successfully", "ToastUserPasswordMismatch": "Passwords do not match", "ToastUserPasswordMustChange": "New password cannot match old password", - "ToastUserRootRequireName": "Must enter a root username" + "ToastUserRootRequireName": "Must enter a root username", + "ToastChaptersAllLocked": "All chapters are locked. Unlock some chapters to shift their times.", + "ToastChapterInvalidShiftAmount": "Invalid shift amount. First chapter would have zero or negative length.", + "ToastBulkChapterInvalidCount": "Please enter a valid number between 1 and 150", + "PlaceholderBulkChapterInput": "Enter chapter title or use numbering (e.g., 'Episode 1', 'Chapter 10', '1.')", + "HeaderBulkChapterModal": "Add Multiple Chapters", + "MessageBulkChapterPattern": "How many chapters would you like to add with this numbering pattern?", + "LabelDetectedPattern": "Detected pattern:", + "LabelNextChapters": "Next chapters will be:", + "LabelNumberOfChapters": "Number of chapters:", + "TooltipAddChapters": "Add chapter(s)", + "TooltipAddOneSecond": "Add 1 second", + "TooltipLockAllChapters": "Lock all chapters", + "TooltipLockChapter": "Lock chapter (Shift+click for range)", + "TooltipSubtractOneSecond": "Subtract 1 second", + "TooltipUnlockAllChapters": "Unlock all chapters", + "TooltipUnlockChapter": "Unlock chapter (Shift+click for range)" } diff --git a/client/strings/es.json b/client/strings/es.json index 4dac82720..d81236f0c 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -1103,5 +1103,21 @@ "ToastUserPasswordChangeSuccess": "Contraseña modificada correctamente", "ToastUserPasswordMismatch": "No coinciden las contraseñas", "ToastUserPasswordMustChange": "La nueva contraseña no puede ser igual que la anterior", - "ToastUserRootRequireName": "Debe introducir un nombre de usuario administrativo" + "ToastUserRootRequireName": "Debe introducir un nombre de usuario administrativo", + "ToastChaptersAllLocked": "Todos los capítulos están bloqueados. Desbloquee algunos capítulos para cambiar sus tiempos.", + "ToastChapterInvalidShiftAmount": "Cantidad de desplazamiento inválida. El primer capítulo tendría duración cero o negativa.", + "ToastBulkChapterInvalidCount": "Por favor ingrese un número válido entre 1 y 150", + "PlaceholderBulkChapterInput": "Ingrese título de capítulo o use numeración (ej. 'Episodio 1', 'Capítulo 10', '1.')", + "HeaderBulkChapterModal": "Añadir Múltiples Capítulos", + "MessageBulkChapterPattern": "¿Cuántos capítulos desea añadir con este patrón de numeración?", + "LabelDetectedPattern": "Patrón detectado:", + "LabelNextChapters": "Los próximos capítulos serán:", + "LabelNumberOfChapters": "Número de capítulos:", + "TooltipAddChapters": "Añadir capítulo(s)", + "TooltipAddOneSecond": "Añadir 1 segundo", + "TooltipLockAllChapters": "Bloquear todos los capítulos", + "TooltipLockChapter": "Bloquear capítulo (Mayús+clic para rango)", + "TooltipSubtractOneSecond": "Restar 1 segundo", + "TooltipUnlockAllChapters": "Desbloquear todos los capítulos", + "TooltipUnlockChapter": "Desbloquear capítulo (Mayús+clic para rango)" } diff --git a/client/strings/fr.json b/client/strings/fr.json index 03a0cdeef..88666f580 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -1102,5 +1102,21 @@ "ToastUserPasswordChangeSuccess": "Mot de passe modifié avec succès", "ToastUserPasswordMismatch": "Les mots de passe ne correspondent pas", "ToastUserPasswordMustChange": "Le nouveau mot de passe ne peut pas être identique à l’ancien", - "ToastUserRootRequireName": "Vous devez entrer un nom d’utilisateur root" -} + "ToastUserRootRequireName": "Vous devez entrer un nom d’utilisateur root", + "ToastChaptersAllLocked": "Tous les chapitres sont verrouillés. Déverrouillez certains chapitres pour décaler leurs temps.", + "ToastChapterInvalidShiftAmount": "Montant de décalage invalide. Le premier chapitre aurait une durée nulle ou négative.", + "ToastBulkChapterInvalidCount": "Veuillez entrer un nombre valide entre 1 et 150", + "PlaceholderBulkChapterInput": "Entrez le titre du chapitre ou utilisez la numérotation (ex. 'Épisode 1', 'Chapitre 10', '1.')", + "HeaderBulkChapterModal": "Ajouter Plusieurs Chapitres", + "MessageBulkChapterPattern": "Combien de chapitres souhaitez-vous ajouter avec ce motif de numérotation ?", + "LabelDetectedPattern": "Motif détecté :", + "LabelNextChapters": "Les prochains chapitres seront :", + "LabelNumberOfChapters": "Nombre de chapitres :", + "TooltipAddChapters": "Ajouter chapitre(s)", + "TooltipAddOneSecond": "Ajouter 1 seconde", + "TooltipLockAllChapters": "Verrouiller tous les chapitres", + "TooltipLockChapter": "Verrouiller le chapitre (Maj+clic pour plage)", + "TooltipSubtractOneSecond": "Soustraire 1 seconde", + "TooltipUnlockAllChapters": "Déverrouiller tous les chapitres", + "TooltipUnlockChapter": "Déverrouiller le chapitre (Maj+clic pour plage)" +} \ No newline at end of file