diff --git a/client/pages/audiobook/_id/chapters.vue b/client/pages/audiobook/_id/chapters.vue index a8840744..10840f25 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 939eb9f4..39726ff0 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 4dac8272..d81236f0 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 03a0cdee..88666f58 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