merge upstream

This commit is contained in:
David Leimroth 2022-02-28 18:18:25 +01:00
commit 61bf30e08a
89 changed files with 2963 additions and 1805 deletions

View file

@ -1,158 +0,0 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global['fast-sort'] = {}));
}(this, (function (exports) {
'use strict';
// >>> INTERFACES <<<
// >>> HELPERS <<<
var castComparer = function (comparer) { return function (a, b, order) { return comparer(a, b, order) * order; }; };
var throwInvalidConfigErrorIfTrue = function (condition, context) {
if (condition)
throw Error("Invalid sort config: " + context);
};
var unpackObjectSorter = function (sortByObj) {
var _a = sortByObj || {}, asc = _a.asc, desc = _a.desc;
var order = asc ? 1 : -1;
var sortBy = (asc || desc);
// Validate object config
throwInvalidConfigErrorIfTrue(!sortBy, 'Expected `asc` or `desc` property');
throwInvalidConfigErrorIfTrue(asc && desc, 'Ambiguous object with `asc` and `desc` config properties');
var comparer = sortByObj.comparer && castComparer(sortByObj.comparer);
return { order: order, sortBy: sortBy, comparer: comparer };
};
// >>> SORTERS <<<
var multiPropertySorterProvider = function (defaultComparer) {
return function multiPropertySorter(sortBy, sortByArr, depth, order, comparer, a, b) {
var valA;
var valB;
if (typeof sortBy === 'string') {
valA = a[sortBy];
valB = b[sortBy];
}
else if (typeof sortBy === 'function') {
valA = sortBy(a);
valB = sortBy(b);
}
else {
var objectSorterConfig = unpackObjectSorter(sortBy);
return multiPropertySorter(objectSorterConfig.sortBy, sortByArr, depth, objectSorterConfig.order, objectSorterConfig.comparer || defaultComparer, a, b);
}
var equality = comparer(valA, valB, order);
if ((equality === 0 || (valA == null && valB == null)) &&
sortByArr.length > depth) {
return multiPropertySorter(sortByArr[depth], sortByArr, depth + 1, order, comparer, a, b);
}
return equality;
};
};
function getSortStrategy(sortBy, comparer, order) {
// Flat array sorter
if (sortBy === undefined || sortBy === true) {
return function (a, b) { return comparer(a, b, order); };
}
// Sort list of objects by single object key
if (typeof sortBy === 'string') {
throwInvalidConfigErrorIfTrue(sortBy.includes('.'), 'String syntax not allowed for nested properties.');
return function (a, b) { return comparer(a[sortBy], b[sortBy], order); };
}
// Sort list of objects by single function sorter
if (typeof sortBy === 'function') {
return function (a, b) { return comparer(sortBy(a), sortBy(b), order); };
}
// Sort by multiple properties
if (Array.isArray(sortBy)) {
var multiPropSorter_1 = multiPropertySorterProvider(comparer);
return function (a, b) { return multiPropSorter_1(sortBy[0], sortBy, 1, order, comparer, a, b); };
}
// Unpack object config to get actual sorter strategy
var objectSorterConfig = unpackObjectSorter(sortBy);
return getSortStrategy(objectSorterConfig.sortBy, objectSorterConfig.comparer || comparer, objectSorterConfig.order);
}
var sortArray = function (order, ctx, sortBy, comparer) {
var _a;
if (!Array.isArray(ctx)) {
return ctx;
}
// Unwrap sortBy if array with only 1 value to get faster sort strategy
if (Array.isArray(sortBy) && sortBy.length < 2) {
_a = sortBy, sortBy = _a[0];
}
return ctx.sort(getSortStrategy(sortBy, comparer, order));
};
// >>> Public <<<
var createNewSortInstance = function (opts) {
var comparer = castComparer(opts.comparer);
return function (_ctx) {
var ctx = Array.isArray(_ctx) && !opts.inPlaceSorting
? _ctx.slice()
: _ctx;
return {
/**
* Sort array in ascending order.
* @example
* sort([3, 1, 4]).asc();
* sort(users).asc(u => u.firstName);
* sort(users).asc([
* U => u.firstName
* u => u.lastName,
* ]);
*/
asc: function (sortBy) {
return sortArray(1, ctx, sortBy, comparer);
},
/**
* Sort array in descending order.
* @example
* sort([3, 1, 4]).desc();
* sort(users).desc(u => u.firstName);
* sort(users).desc([
* U => u.firstName
* u => u.lastName,
* ]);
*/
desc: function (sortBy) {
return sortArray(-1, ctx, sortBy, comparer);
},
/**
* Sort array in ascending or descending order. It allows sorting on multiple props
* in different order for each of them.
* @example
* sort(users).by([
* { asc: u => u.score }
* { desc: u => u.age }
* ]);
*/
by: function (sortBy) {
return sortArray(1, ctx, sortBy, comparer);
},
};
};
};
var defaultComparer = function (a, b, order) {
if (a == null)
return order;
if (b == null)
return -order;
if (a < b)
return -1;
if (a === b)
return 0;
return 1;
};
var sort = createNewSortInstance({
comparer: defaultComparer,
});
var inPlaceSort = createNewSortInstance({
comparer: defaultComparer,
inPlaceSorting: true,
});
exports.createNewSortInstance = createNewSortInstance;
exports.inPlaceSort = inPlaceSort;
exports.sort = sort;
Object.defineProperty(exports, '__esModule', { value: true });
})));

View file

@ -1,13 +1,6 @@
<template>
<div class="w-full -mt-6">
<div class="w-full relative mb-1">
<!-- <div class="absolute top-0 left-0 w-full h-full bg-red flex items-end pointer-events-none">
<p ref="currentTimestamp" class="font-mono text-sm text-gray-100 pointer-events-auto">00:00:00</p>
<p class="font-mono text-sm text-gray-100 pointer-events-auto">&nbsp;/&nbsp;{{ progressPercent }}%</p>
<div class="flex-grow" />
<p class="font-mono text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
</div> -->
<div v-if="chapters.length" class="hidden md:flex absolute right-20 top-0 bottom-0 h-full items-end">
<div class="cursor-pointer text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
<span class="material-icons text-3xl">format_list_bulleted</span>
@ -19,7 +12,7 @@
</div>
</div>
<div class="absolute top-0 bottom-0 h-full hidden md:flex items-end" :class="chapters.length ? ' right-44' : 'right-32'">
<controls-volume-control ref="volumeControl" v-model="volume" @input="updateVolume" />
<controls-volume-control ref="volumeControl" v-model="volume" @input="setVolume" />
</div>
<div class="flex pb-4 md:pb-2">
@ -28,13 +21,13 @@
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-8" @mousedown.prevent @mouseup.prevent @click.stop="restart">
<span class="material-icons text-3xl">first_page</span>
</div>
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="backward10">
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
<span class="material-icons text-3xl">replay_10</span>
</div>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
<span class="material-icons">{{ seekLoading ? 'autorenew' : isPaused ? 'play_arrow' : 'pause' }}</span>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
<span class="material-icons">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
</div>
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="forward10">
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
<span class="material-icons text-3xl">forward_10</span>
</div>
<controls-playback-speed-control v-model="playbackRate" @input="playbackRateUpdated" @change="playbackRateChanged" />
@ -82,20 +75,15 @@
<p class="font-mono text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
</div>
<audio ref="audio" @progress="progress" @timeupdate="timeupdate" @loadedmetadata="audioLoadedMetadata" @play="audioPlayed" @pause="audioPaused" @error="audioError" @ended="audioEnded" @stalled="audioStalled" @suspend="audioSuspended" />
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
</div>
</template>
<script>
import Hls from 'hls.js'
export default {
props: {
streamId: String,
audiobookId: String,
loading: Boolean,
paused: Boolean,
chapters: {
type: Array,
default: () => []
@ -107,57 +95,41 @@ export default {
},
data() {
return {
hlsInstance: null,
staleHlsInstance: null,
usingNativeAudioPlayer: false,
playOnLoad: false,
startTime: 0,
volume: 1,
playbackRate: 1,
trackWidth: 0,
isPaused: true,
url: null,
src: null,
playedTrackWidth: 0,
bufferTrackWidth: 0,
readyTrackWidth: 0,
audioEl: null,
totalDuration: 0,
seekedTime: 0,
seekLoading: false,
showChaptersModal: false,
currentTime: 0,
trackOffsetLeft: 16, // Track is 16px from edge
listenTimeInterval: null,
listeningTimeSinceLastUpdate: 0,
totalListeningTimeInSession: 0
duration: 0
}
},
computed: {
token() {
return this.$store.getters['user/getToken']
},
totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration)
},
timeRemaining() {
if (!this.audioEl) return 0
return (this.totalDuration - this.currentTime) / this.playbackRate
return (this.duration - this.currentTime) / this.playbackRate
},
timeRemainingPretty() {
if (this.timeRemaining < 0) {
console.warn('Time remaining < 0', this.totalDuration, this.currentTime, this.timeRemaining)
console.warn('Time remaining < 0', this.duration, this.currentTime, this.timeRemaining)
return this.$secondsToTimestamp(this.timeRemaining * -1)
}
return '-' + this.$secondsToTimestamp(this.timeRemaining)
},
progressPercent() {
if (!this.totalDuration) return 0
return Math.round((100 * this.currentTime) / this.totalDuration)
if (!this.duration) return 0
return Math.round((100 * this.currentTime) / this.duration)
},
chapterTicks() {
return this.chapters.map((chap) => {
var perc = chap.start / this.totalDuration
var perc = chap.start / this.duration
return {
title: chap.title,
left: perc * this.trackWidth
@ -175,188 +147,77 @@ export default {
}
},
methods: {
audioPlayed() {
if (!this.$refs.audio) return
console.log('Audio Played', this.$refs.audio.currentTime, 'Total Duration', this.$refs.audio.duration)
this.startListenTimeInterval()
this.isPaused = this.$refs.audio.paused
setDuration(duration) {
this.duration = duration
},
audioPaused() {
if (!this.$refs.audio) return
// console.log('Audio Paused', this.$refs.audio.paused, this.$refs.audio.currentTime)
this.isPaused = this.$refs.audio.paused
this.cancelListenTimeInterval()
setCurrentTime(time) {
this.currentTime = time
this.updateTimestamp()
this.updatePlayedTrack()
},
audioError(err) {
if (!this.$refs.audio) return
console.error('Audio Error', this.$refs.audio.paused, this.$refs.audio.currentTime, err)
playPause() {
this.$emit('playPause')
},
audioEnded() {
if (!this.$refs.audio) return
console.log('Audio Ended', this.$refs.audio.paused, this.$refs.audio.currentTime)
jumpBackward() {
this.$emit('jumpBackward')
},
audioStalled() {
if (!this.$refs.audio) return
console.warn('Audio Stalled', this.$refs.audio.paused, this.$refs.audio.currentTime)
jumpForward() {
this.$emit('jumpForward')
},
audioSuspended() {
if (!this.$refs.audio) return
console.warn('Audio Suspended', this.$refs.audio.paused, this.$refs.audio.currentTime)
increaseVolume() {
if (this.volume >= 1) return
this.volume = Math.min(1, this.volume + 0.1)
this.setVolume(this.volume)
},
sendStreamSync(timeListened = 0) {
// If currentTime is null then currentTime wont be updated
var currentTime = null
if (this.$refs.audio) {
currentTime = this.$refs.audio.currentTime
} else if (!timeListened) {
console.warn('Not sending stream sync, no data to sync')
return
decreaseVolume() {
if (this.volume <= 0) return
this.volume = Math.max(0, this.volume - 0.1)
this.setVolume(this.volume)
},
setVolume(volume) {
this.$emit('setVolume', volume)
},
toggleMute() {
if (this.$refs.volumeControl && this.$refs.volumeControl.toggleMute) {
this.$refs.volumeControl.toggleMute()
}
var syncData = {
timeListened,
currentTime,
streamId: this.streamId,
audiobookId: this.audiobookId
}
this.$emit('sync', syncData)
},
sendAddListeningTime() {
var listeningTimeToAdd = Math.floor(this.listeningTimeSinceLastUpdate)
this.listeningTimeSinceLastUpdate = Math.max(0, this.listeningTimeSinceLastUpdate - listeningTimeToAdd)
this.sendStreamSync(listeningTimeToAdd)
increasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
if (currentRateIndex >= rates.length - 1) return
this.playbackRate = rates[currentRateIndex + 1] || 1
this.playbackRateChanged(this.playbackRate)
},
cancelListenTimeInterval() {
this.sendAddListeningTime()
clearInterval(this.listenTimeInterval)
this.listenTimeInterval = null
decreasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
if (currentRateIndex <= 0) return
this.playbackRate = rates[currentRateIndex - 1] || 1
this.playbackRateChanged(this.playbackRate)
},
startListenTimeInterval() {
if (!this.$refs.audio) return
clearInterval(this.listenTimeInterval)
var lastTime = this.$refs.audio.currentTime
var lastTick = Date.now()
var noProgressCount = 0
this.listenTimeInterval = setInterval(() => {
if (!this.$refs.audio) {
console.error('Canceling audio played interval no audio player')
this.cancelListenTimeInterval()
return
}
if (this.$refs.audio.paused) {
console.warn('Canceling audio played interval audio player paused')
this.cancelListenTimeInterval()
return
}
var timeSinceLastTick = Date.now() - lastTick
lastTick = Date.now()
var expectedAudioTime = lastTime + timeSinceLastTick / 1000
var currentTime = this.$refs.audio.currentTime
var differenceFromExpected = expectedAudioTime - currentTime
if (currentTime === lastTime) {
noProgressCount++
if (noProgressCount > 3) {
console.error('Audio current time has not increased - cancel interval and pause player')
this.cancelListenTimeInterval()
this.pause()
}
} else if (Math.abs(differenceFromExpected) > 0.1) {
noProgressCount = 0
console.warn('Invalid time between interval - resync last', differenceFromExpected)
lastTime = currentTime
} else {
noProgressCount = 0
var exactPlayTimeDifference = currentTime - lastTime
// console.log('Difference from expected', differenceFromExpected, 'Exact play time diff', exactPlayTimeDifference)
lastTime = currentTime
this.listeningTimeSinceLastUpdate += exactPlayTimeDifference
this.totalListeningTimeInSession += exactPlayTimeDifference
// console.log('Time since last update:', this.listeningTimeSinceLastUpdate, 'Session listening time:', this.totalListeningTimeInSession)
if (this.listeningTimeSinceLastUpdate > 5) {
this.sendAddListeningTime()
}
}
}, 1000)
setPlaybackRate(playbackRate) {
this.$emit('setPlaybackRate', playbackRate)
},
selectChapter(chapter) {
this.seek(chapter.start)
this.showChaptersModal = false
},
selectBookmark(bookmark) {
if (bookmark) {
this.seek(bookmark.time)
}
},
seek(time) {
if (this.loading) {
return
}
if (this.seekLoading) {
console.error('Already seek loading', this.seekedTime)
return
}
if (!this.audioEl) {
console.error('No Audio el for seek', time)
return
}
if (!this.audioEl.paused) {
this.cancelListenTimeInterval()
}
this.seekedTime = time
this.seekLoading = true
this.audioEl.currentTime = time
this.sendStreamSync()
this.$nextTick(() => {
if (this.audioEl && !this.audioEl.paused) {
this.startListenTimeInterval()
}
})
if (this.$refs.playedTrack) {
var perc = time / this.audioEl.duration
var ptWidth = Math.round(perc * this.trackWidth)
this.$refs.playedTrack.style.width = ptWidth + 'px'
this.playedTrackWidth = ptWidth
this.$refs.playedTrack.classList.remove('bg-gray-200')
this.$refs.playedTrack.classList.add('bg-yellow-300')
}
},
updateVolume(volume) {
if (this.audioEl) {
this.audioEl.volume = volume
}
},
updatePlaybackRate(playbackRate) {
if (this.audioEl) {
try {
this.audioEl.playbackRate = playbackRate
this.audioEl.defaultPlaybackRate = playbackRate
} catch (error) {
console.error('Update playback rate failed', error)
}
} else {
console.error('No Audio El updatePlaybackRate')
}
this.$emit('seek', time)
},
playbackRateUpdated(playbackRate) {
this.updatePlaybackRate(playbackRate)
this.setPlaybackRate(playbackRate)
},
playbackRateChanged(playbackRate) {
this.updatePlaybackRate(playbackRate)
this.setPlaybackRate(playbackRate)
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
console.error('Failed to update settings', err)
})
},
mousemoveTrack(e) {
var offsetX = e.offsetX
var time = (offsetX / this.trackWidth) * this.totalDuration
var time = (offsetX / this.trackWidth) * this.duration
if (this.$refs.hoverTimestamp) {
var width = this.$refs.hoverTimestamp.clientWidth
this.$refs.hoverTimestamp.style.opacity = 1
@ -402,17 +263,6 @@ export default {
},
restart() {
this.seek(0)
this.$nextTick(this.sendStreamSync)
},
backward10() {
var newTime = this.audioEl.currentTime - 10
newTime = Math.max(0, newTime)
this.seek(newTime)
},
forward10() {
var newTime = this.audioEl.currentTime + 10
newTime = Math.min(this.audioEl.duration, newTime)
this.seek(newTime)
},
setStreamReady() {
this.readyTrackWidth = this.trackWidth
@ -442,114 +292,11 @@ export default {
console.error('No timestamp el')
return
}
if (!this.audioEl) {
console.error('No Audio El')
return
}
var currTimeClean = this.$secondsToTimestamp(this.audioEl.currentTime)
var currTimeClean = this.$secondsToTimestamp(this.currentTime)
ts.innerText = currTimeClean
},
clickTrack(e) {
var offsetX = e.offsetX
var perc = offsetX / this.trackWidth
var time = perc * this.audioEl.duration
if (isNaN(time) || time === null) {
console.error('Invalid time', perc, time)
return
}
this.seek(time)
},
playPauseClick() {
if (this.isPaused) {
this.play()
} else {
this.pause()
}
},
isValidDuration(duration) {
if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) {
return true
}
return false
},
getBufferedRanges() {
if (!this.audioEl) return []
const ranges = []
const seekable = this.audioEl.buffered || []
let offset = 0
for (let i = 0, length = seekable.length; i < length; i++) {
let start = seekable.start(i)
let end = seekable.end(i)
if (!this.isValidDuration(start)) {
start = 0
}
if (!this.isValidDuration(end)) {
end = 0
continue
}
ranges.push({
start: start + offset,
end: end + offset
})
}
return ranges
},
getLastBufferedTime() {
var bufferedRanges = this.getBufferedRanges()
if (!bufferedRanges.length) return 0
var buff = bufferedRanges.find((buff) => buff.start < this.audioEl.currentTime && buff.end > this.audioEl.currentTime)
if (buff) return buff.end
var last = bufferedRanges[bufferedRanges.length - 1]
return last.end
},
progress() {
if (!this.audioEl) {
return
}
var lastbuff = this.getLastBufferedTime()
var bufferlen = (lastbuff / this.audioEl.duration) * this.trackWidth
bufferlen = Math.round(bufferlen)
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
this.$refs.bufferTrack.style.width = bufferlen + 'px'
this.bufferTrackWidth = bufferlen
},
timeupdate() {
if (!this.$refs.playedTrack) {
console.error('Invalid no played track ref')
return
}
if (!this.audioEl) {
console.error('No Audio El')
return
}
if (this.seekLoading) {
this.seekLoading = false
if (this.$refs.playedTrack) {
this.$refs.playedTrack.classList.remove('bg-yellow-300')
this.$refs.playedTrack.classList.add('bg-gray-200')
}
}
this.updateTimestamp()
// Send update to server when currentTime > 0
// this prevents errors when seeking to position not yet transcoded
// seeking to position not yet transcoded will cause audio element to set currentTime to 0
// if (this.audioEl.currentTime) {
// this.sendStreamUpdate()
// }
this.currentTime = this.audioEl.currentTime
var perc = this.audioEl.currentTime / this.audioEl.duration
updatePlayedTrack() {
var perc = this.currentTime / this.duration
var ptWidth = Math.round(perc * this.trackWidth)
if (this.playedTrackWidth === ptWidth) {
return
@ -557,83 +304,27 @@ export default {
this.$refs.playedTrack.style.width = ptWidth + 'px'
this.playedTrackWidth = ptWidth
},
audioLoadedMetadata() {
this.totalDuration = this.audioEl.duration
this.$emit('loaded', this.totalDuration)
if (this.usingNativeAudioPlayer) {
this.audioEl.currentTime = this.startTime
this.play()
clickTrack(e) {
if (this.loading) return
var offsetX = e.offsetX
var perc = offsetX / this.trackWidth
var time = perc * this.duration
if (isNaN(time) || time === null) {
console.error('Invalid time', perc, time)
return
}
this.seek(time)
},
set(url, currentTime, playOnLoad = false) {
if (this.hlsInstance) {
this.terminateStream()
}
if (!this.$refs.audio) {
console.error('No audio widget')
setBufferTime(bufferTime) {
if (!this.audioEl) {
return
}
this.listeningTimeSinceLastUpdate = 0
this.playOnLoad = playOnLoad
this.startTime = currentTime
this.url = url
if (process.env.NODE_ENV === 'development') {
url = `${process.env.serverUrl}${url}`
}
this.src = url
console.log('[AudioPlayer-Set] Set url', url)
var audio = this.$refs.audio
audio.volume = this.volume
audio.defaultPlaybackRate = this.playbackRate
// iOS does not support Media Elements but allows for HLS in the native audio player
if (!Hls.isSupported()) {
console.warn('HLS is not supported - fallback to using audio element')
this.usingNativeAudioPlayer = true
audio.src = this.src + '?token=' + this.token
audio.currentTime = currentTime
return
}
var hlsOptions = {
startPosition: currentTime || -1,
xhrSetup: (xhr) => {
xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
}
}
console.log('Starting HLS audio stream at time', currentTime)
// console.log('[AudioPlayer-Set] HLS Config', hlsOptions)
this.hlsInstance = new Hls(hlsOptions)
this.hlsInstance.attachMedia(audio)
this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
// console.log('[HLS] MEDIA ATTACHED')
this.hlsInstance.loadSource(url)
this.hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
console.log('[HLS] Manifest Parsed')
if (playOnLoad) {
this.play()
}
})
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
console.error('[HLS] Error', data.type, data.details, data)
if (this.$refs.audio) {
console.log('Hls error check audio', this.$refs.audio.paused, this.$refs.audio.currentTime, this.$refs.audio.readyState)
}
if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
console.error('[HLS] BUFFER STALLED ERROR')
}
})
this.hlsInstance.on(Hls.Events.DESTROYING, () => {
console.log('[HLS] Destroying HLS Instance')
})
})
var bufferlen = (bufferTime / this.duration) * this.trackWidth
bufferlen = Math.round(bufferlen)
if (this.bufferTrackWidth === bufferlen || !this.$refs.bufferTrack) return
this.$refs.bufferTrack.style.width = bufferlen + 'px'
this.bufferTrackWidth = bufferlen
},
showChapters() {
if (!this.chapters.length) return
@ -642,39 +333,9 @@ export default {
showBookmarks() {
this.$emit('showBookmarks', this.currentTime)
},
play() {
if (!this.$refs.audio) {
console.error('No Audio ref')
return
}
this.$refs.audio.play()
},
pause() {
if (!this.$refs.audio) return
this.$refs.audio.pause()
},
terminateStream() {
if (this.hlsInstance) {
if (!this.hlsInstance.destroy) {
console.error('HLS Instance has no destroy property', this.hlsInstance)
return
}
this.staleHlsInstance = this.hlsInstance
this.staleHlsInstance.destroy()
this.hlsInstance = null
}
},
async resetStream(startTime) {
if (this.$refs.audio) this.$refs.audio.pause()
this.terminateStream()
await new Promise((resolve) => setTimeout(resolve, 1000))
console.log('Waited 1 second after terminating stream to start again')
this.set(this.url, startTime, true)
},
init() {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
this.audioEl = this.$refs.audio
this.$emit('setPlaybackRate', this.playbackRate)
this.setTrackWidth()
},
setTrackWidth() {
@ -686,48 +347,19 @@ export default {
},
settingsUpdated(settings) {
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
this.updatePlaybackRate(settings.playbackRate)
this.setPlaybackRate(settings.playbackRate)
}
},
volumeUp() {
if (this.volume >= 1) return
this.volume = Math.min(1, this.volume + 0.1)
this.updateVolume(this.volume)
},
volumeDown() {
if (this.volume <= 0) return
this.volume = Math.max(0, this.volume - 0.1)
this.updateVolume(this.volume)
},
toggleMute() {
if (this.$refs.volumeControl && this.$refs.volumeControl.toggleMute) {
this.$refs.volumeControl.toggleMute()
}
},
increasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
if (currentRateIndex >= rates.length - 1) return
this.playbackRate = rates[currentRateIndex + 1] || 1
this.playbackRateChanged(this.playbackRate)
},
decreasePlaybackRate() {
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
if (currentRateIndex <= 0) return
this.playbackRate = rates[currentRateIndex - 1] || 1
this.playbackRateChanged(this.playbackRate)
},
closePlayer() {
if (this.loading) return
this.$emit('close')
},
hotkey(action) {
if (action === this.$hotkeys.AudioPlayer.PLAY_PAUSE) this.playPauseClick()
else if (action === this.$hotkeys.AudioPlayer.JUMP_FORWARD) this.forward10()
else if (action === this.$hotkeys.AudioPlayer.JUMP_BACKWARD) this.backward10()
else if (action === this.$hotkeys.AudioPlayer.VOLUME_UP) this.volumeUp()
else if (action === this.$hotkeys.AudioPlayer.VOLUME_DOWN) this.volumeDown()
if (action === this.$hotkeys.AudioPlayer.PLAY_PAUSE) this.playPause()
else if (action === this.$hotkeys.AudioPlayer.JUMP_FORWARD) this.jumpForward()
else if (action === this.$hotkeys.AudioPlayer.JUMP_BACKWARD) this.jumpBackward()
else if (action === this.$hotkeys.AudioPlayer.VOLUME_UP) this.increaseVolume()
else if (action === this.$hotkeys.AudioPlayer.VOLUME_DOWN) this.decreaseVolume()
else if (action === this.$hotkeys.AudioPlayer.MUTE_UNMUTE) this.toggleMute()
else if (action === this.$hotkeys.AudioPlayer.SHOW_CHAPTERS) this.showChapters()
else if (action === this.$hotkeys.AudioPlayer.INCREASE_PLAYBACK_RATE) this.increasePlaybackRate()

View file

@ -15,6 +15,13 @@
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
<span class="material-icons-outlined text-warning text-opacity-50"> cast </span>
</ui-tooltip>
<div v-if="isChromecastInitialized" class="w-6 h-6 mr-2 cursor-pointer">
<google-cast-launcher></google-cast-launcher>
</div>
<nuxt-link to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<span class="material-icons">equalizer</span>
</nuxt-link>
@ -39,10 +46,6 @@
<div v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
<h1 class="text-2xl px-4">{{ numAudiobooksSelected }} Selected</h1>
<!-- <ui-btn v-show="!isHome" small class="text-sm mx-2" @click="toggleSelectAll"
>{{ isAllSelected ? 'Select None' : 'Select All' }}<span class="pl-2">({{ entitiesLoaded }})</span></ui-btn
> -->
<div class="flex-grow" />
<ui-tooltip :text="`Mark as ${selectedIsRead ? 'Not Read' : 'Read'}`" direction="bottom">
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsRead" @click="toggleBatchRead" class="mx-1.5" />
@ -124,6 +127,15 @@ export default {
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
isChromecastEnabled() {
return this.$store.getters['getServerSetting']('chromecastEnabled')
},
isChromecastInitialized() {
return this.$store.state.globals.isChromecastInitialized
},
isHttps() {
return location.protocol === 'https:' || process.env.NODE_ENV === 'development'
}
},
methods: {

View file

@ -168,8 +168,7 @@ export default {
})
},
startStream() {
this.$store.commit('setStreamAudiobook', this.book)
this.$root.socket.emit('open_stream', this.book.id)
this.$eventBus.$emit('play-audiobook', this.book.id)
},
editClick() {
this.$emit('edit', this.book)

View file

@ -2,9 +2,6 @@
<div id="bookshelf" class="w-full overflow-y-auto">
<template v-for="shelf in totalShelves">
<div :key="shelf" :id="`shelf-${shelf - 1}`" class="w-full px-4 sm:px-8 relative" :class="{ bookshelfRow: !isAlternativeBookshelfView }" :style="{ height: shelfHeight + 'px' }">
<!-- <div class="absolute top-0 left-0 bottom-0 p-4 z-10">
<p class="text-white text-2xl">{{ shelf }}</p>
</div> -->
<div v-if="!isAlternativeBookshelfView" class="bookshelfDivider w-full absolute bottom-0 left-0 right-0 z-20" :class="`h-${shelfDividerHeightIndex}`" />
</div>
</template>
@ -26,7 +23,9 @@
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
<!-- Experimental Bookshelf Texture -->
<div v-show="showExperimentalFeatures" class="fixed bottom-4 right-28 z-40">
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div>
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal">
<p class="text-sm py-0.5">Texture</p>
</div>
</div>
</div>
</template>
@ -114,6 +113,9 @@ export default {
bookshelfView() {
return this.$store.getters['settings/getServerSetting']('bookshelfView')
},
sortingIgnorePrefix() {
return this.$store.getters['getServerSetting']('sortingIgnorePrefix')
},
isCoverSquareAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
},
@ -245,7 +247,7 @@ export default {
console.error('failed to fetch books', error)
return null
})
console.log('payload', payload)
this.isFetchingEntities = false
if (this.pendingReset) {
this.pendingReset = false

View file

@ -6,7 +6,7 @@
<div class="flex items-start pl-24 mb-6 md:mb-0">
<div>
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer text-base sm:text-lg">
{{ title }} <span v-if="stream && $isDev" class="text-xs text-gray-400">({{ stream.id }})</span>
{{ title }}
</nuxt-link>
<div class="text-gray-400 flex items-center">
<span class="material-icons text-sm">person</span>
@ -22,26 +22,29 @@
</div>
</div>
<div class="flex-grow" />
<span v-if="stream" class="material-icons p-4 cursor-pointer" @click="cancelStream">close</span>
<span class="material-icons p-4 cursor-pointer" @click="closePlayer">close</span>
</div>
<audio-player ref="audioPlayer" :stream-id="streamId" :audiobook-id="audiobookId" :chapters="chapters" :loading="isLoading" :bookmarks="bookmarks" @close="cancelStream" @loaded="(d) => (totalDuration = d)" @showBookmarks="showBookmarks" @sync="sendStreamSync" @hook:mounted="audioPlayerMounted" />
<audio-player ref="audioPlayer" :chapters="chapters" :paused="!isPlaying" :loading="playerLoading" :bookmarks="bookmarks" @playPause="playPause" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setVolume="setVolume" @setPlaybackRate="setPlaybackRate" @seek="seek" @close="closePlayer" @showBookmarks="showBookmarks" />
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :audiobook-id="bookmarkAudiobookId" :current-time="bookmarkCurrentTime" @select="selectBookmark" />
</div>
</template>
<script>
import PlayerHandler from '@/players/PlayerHandler'
export default {
data() {
return {
audioPlayerReady: false,
lastServerUpdateSentSeconds: 0,
stream: null,
playerHandler: new PlayerHandler(this),
totalDuration: 0,
showBookmarksModal: false,
bookmarkCurrentTime: 0,
bookmarkAudiobookId: null
bookmarkAudiobookId: null,
playerLoading: false,
isPlaying: false,
currentTime: 0
}
},
computed: {
@ -69,18 +72,13 @@ export default {
if (!this.audiobookId) return
return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
},
userAudiobookCurrentTime() {
return this.userAudiobook ? this.userAudiobook.currentTime || 0 : 0
},
bookmarks() {
if (!this.userAudiobook) return []
return (this.userAudiobook.bookmarks || []).map((bm) => ({ ...bm })).sort((a, b) => a.time - b.time)
},
isLoading() {
if (!this.streamAudiobook) return false
if (this.stream) {
// IF Stream exists, set loading if stream is diff from next stream
return this.stream.audiobook.id !== this.streamAudiobook.id
}
return true
},
streamAudiobook() {
return this.$store.state.streamAudiobook
},
@ -105,12 +103,6 @@ export default {
authorsList() {
return this.authorFL ? this.authorFL.split(', ') : []
},
streamId() {
return this.stream ? this.stream.id : null
},
playlistUrl() {
return this.stream ? this.stream.clientPlaylistUri : null
},
libraryId() {
return this.streamAudiobook ? this.streamAudiobook.libraryId : null
},
@ -119,8 +111,40 @@ export default {
}
},
methods: {
addListeningTime(time) {
console.log('Send listening time to server', time)
playPause() {
this.playerHandler.playPause()
},
jumpForward() {
this.playerHandler.jumpForward()
},
jumpBackward() {
this.playerHandler.jumpBackward()
},
setVolume(volume) {
this.playerHandler.setVolume(volume)
},
setPlaybackRate(playbackRate) {
this.playerHandler.setPlaybackRate(playbackRate)
},
seek(time) {
this.playerHandler.seek(time)
},
setCurrentTime(time) {
this.currentTime = time
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setCurrentTime(time)
}
},
setDuration(duration) {
this.totalDuration = duration
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setDuration(duration)
}
},
setBufferTime(buffertime) {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setBufferTime(buffertime)
}
},
showBookmarks(currentTime) {
this.bookmarkAudiobookId = this.audiobookId
@ -128,47 +152,12 @@ export default {
this.showBookmarksModal = true
},
selectBookmark(bookmark) {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.selectBookmark(bookmark)
}
this.seek(bookmark.time)
this.showBookmarksModal = false
},
filterByAuthor() {
if (this.$route.name !== 'index') {
this.$router.push(`/library/${this.libraryId || this.$store.state.libraries.currentLibraryId}/bookshelf`)
}
var settingsUpdate = {
filterBy: `authors.${this.$encode(this.author)}`
}
this.$store.dispatch('user/updateUserSettings', settingsUpdate)
},
audioPlayerMounted() {
this.audioPlayerReady = true
if (this.stream) {
console.log('[STREAM-CONTAINER] audioPlayer Mounted w/ Stream', this.stream)
this.openStream()
}
},
cancelStream() {
this.$root.socket.emit('close_stream')
},
terminateStream() {
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.terminateStream()
}
},
openStream() {
var playOnLoad = this.$store.state.playOnLoad
console.log(`[StreamContainer] openStream PlayOnLoad`, playOnLoad)
if (!this.$refs.audioPlayer) {
console.error('NO Audio Player')
return
}
var currentTime = this.stream.clientCurrentTime || 0
this.$refs.audioPlayer.set(this.playlistUrl, currentTime, playOnLoad)
if (this.stream.isTranscodeComplete) {
this.$refs.audioPlayer.setStreamReady()
}
closePlayer() {
this.playerHandler.closePlayer()
this.$store.commit('setStreamAudiobook', null)
},
streamProgress(data) {
if (!data.numSegments) return
@ -181,21 +170,14 @@ export default {
}
},
streamOpen(stream) {
this.stream = stream
this.$store.commit('updateStreamAudiobook', stream.audiobook)
if (this.$refs.audioPlayer) {
console.log('[StreamContainer] streamOpen', stream)
this.openStream()
} else if (this.audioPlayerReady) {
console.error('No Audio Ref')
}
this.$store.commit('setStreamAudiobook', stream.audiobook)
this.playerHandler.prepareStream(stream)
},
streamClosed(streamId) {
if (this.stream && (this.stream.id === streamId || streamId === 'n/a')) {
this.terminateStream()
this.$store.commit('clearStreamAudiobook', this.stream.audiobook.id)
this.stream = null
// Stream was closed from the server
if (this.playerHandler.isPlayingLocalAudiobook && this.playerHandler.currentStreamId === streamId) {
console.warn('[StreamContainer] Closing stream due to request from server')
this.playerHandler.closePlayer()
}
},
streamReady() {
@ -207,41 +189,42 @@ export default {
}
},
streamError(streamId) {
if (this.stream && (this.stream.id === streamId || streamId === 'n/a')) {
this.terminateStream()
this.$store.commit('clearStreamAudiobook', this.stream.audiobook.id)
this.stream = null
// Stream had critical error from the server
if (this.playerHandler.isPlayingLocalAudiobook && this.playerHandler.currentStreamId === streamId) {
console.warn('[StreamContainer] Closing stream due to stream error from server')
this.playerHandler.closePlayer()
}
},
sendStreamSync(syncData) {
var diff = syncData.currentTime - this.lastServerUpdateSentSeconds
if (Math.abs(diff) < 1 && !syncData.timeListened) {
// No need to sync
return
}
this.$root.socket.emit('stream_sync', syncData)
},
// updateTime(currentTime) {
// var diff = currentTime - this.lastServerUpdateSentSeconds
// if (diff > 4 || diff < 0) {
// this.lastServerUpdateSentSeconds = currentTime
// var updatePayload = {
// currentTime,
// streamId: this.streamId
// }
// this.$root.socket.emit('stream_update', updatePayload)
// }
// },
streamReset({ startTime, streamId }) {
if (streamId !== this.streamId) {
console.error('resetStream StreamId Mismatch', streamId, this.streamId)
return
}
if (this.$refs.audioPlayer) {
console.log(`[STREAM-CONTAINER] streamReset Received for time ${startTime}`)
this.$refs.audioPlayer.resetStream(startTime)
this.playerHandler.resetStream(startTime, streamId)
},
castSessionActive(isActive) {
if (isActive && this.playerHandler.isPlayingLocalAudiobook) {
// Cast session started switch to cast player
this.playerHandler.switchPlayer()
} else if (!isActive && this.playerHandler.isPlayingCastedAudiobook) {
// Cast session ended switch to local player
this.playerHandler.switchPlayer()
}
},
async playAudiobook(audiobookId) {
var audiobook = await this.$axios.$get(`/api/books/${audiobookId}`).catch((error) => {
console.error('Failed to fetch full audiobook', error)
return null
})
if (!audiobook) return
this.$store.commit('setStreamAudiobook', audiobook)
this.playerHandler.load(audiobook, true, this.userAudiobookCurrentTime)
}
},
mounted() {
this.$eventBus.$on('cast-session-active', this.castSessionActive)
this.$eventBus.$on('play-audiobook', this.playAudiobook)
},
beforeDestroy() {
this.$eventBus.$off('cast-session-active', this.castSessionActive)
this.$eventBus.$off('play-audiobook', this.playAudiobook)
}
}
</script>

View file

@ -0,0 +1,120 @@
<template>
<div class="relative w-full py-4 px-6 border border-white border-opacity-10 shadow-lg rounded-md my-6">
<div class="absolute -top-3 -left-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full">
<p class="text-base text-white text-opacity-80 font-mono">#{{ book.index }}</p>
</div>
<div v-if="!processing && !uploadFailed && !uploadSuccess" class="absolute -top-3 -right-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-error cursor-pointer" @click="$emit('remove')">
<span class="text-base text-white text-opacity-80 font-mono material-icons">close</span>
</div>
<template v-if="!uploadSuccess && !uploadFailed">
<widgets-alert v-if="error" type="error">
<p class="text-base">{{ error }}</p>
</widgets-alert>
<div class="flex my-2 -mx-2">
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="bookData.title" :disabled="processing" label="Title" @input="titleUpdated" />
</div>
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="bookData.author" :disabled="processing" label="Author" />
</div>
</div>
<div class="flex my-2 -mx-2">
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="bookData.series" :disabled="processing" label="Series" note="(optional)" />
</div>
<div class="w-1/2 px-2">
<div class="w-full">
<p class="px-1 text-sm font-semibold">Directory <em class="font-normal text-xs pl-2">(auto)</em></p>
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" />
</div>
</div>
</div>
<tables-uploaded-files-table :files="book.bookFiles" title="Book Files" class="mt-8" />
<tables-uploaded-files-table v-if="book.otherFiles.length" title="Other Files" :files="book.otherFiles" />
<tables-uploaded-files-table v-if="book.ignoredFiles.length" title="Ignored Files" :files="book.ignoredFiles" />
</template>
<widgets-alert v-if="uploadSuccess" type="success">
<p class="text-base">Successfully Uploaded!</p>
</widgets-alert>
<widgets-alert v-if="uploadFailed" type="error">
<p class="text-base">Failed to upload</p>
</widgets-alert>
<div v-if="isUploading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20">
<ui-loading-indicator text="Uploading..." />
</div>
</div>
</template>
<script>
import Path from 'path'
export default {
props: {
book: {
type: Object,
default: () => {}
},
processing: Boolean
},
data() {
return {
bookData: {
title: '',
author: '',
series: ''
},
error: '',
isUploading: false,
uploadFailed: false,
uploadSuccess: false
}
},
computed: {
directory() {
if (!this.bookData.title) return ''
if (this.bookData.series && this.bookData.author) {
return Path.join(this.bookData.author, this.bookData.series, this.bookData.title)
} else if (this.bookData.author) {
return Path.join(this.bookData.author, this.bookData.title)
} else {
return this.bookData.title
}
}
},
methods: {
setUploadStatus(status) {
this.isUploading = status === 'uploading'
this.uploadFailed = status === 'failed'
this.uploadSuccess = status === 'success'
},
titleUpdated() {
this.error = ''
},
getData() {
if (!this.bookData.title) {
this.error = 'Must have a title'
return null
}
this.error = ''
var files = this.book.bookFiles.concat(this.book.otherFiles)
return {
index: this.book.index,
...this.bookData,
files
}
}
},
mounted() {
if (this.book) {
this.bookData.title = this.book.title
this.bookData.author = this.book.author
this.bookData.series = this.book.series
}
}
}
</script>

View file

@ -5,11 +5,13 @@
<div class="absolute cover-bg" ref="coverBg" />
</div>
<div v-if="isAlternativeBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${sizeMultiplier * 3}rem` }">
<!-- Alternative bookshelf title/author/sort -->
<div v-if="isAlternativeBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
<span v-if="volumeNumber">#{{ volumeNumber }}&nbsp;</span>{{ title }}
<span v-if="volumeNumber">#{{ volumeNumber }}&nbsp;</span>{{ displayTitle }}
</p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ authorFL }}</p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
</div>
<div v-if="booksInSeries" class="absolute z-20 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ booksInSeries }}</div>
@ -38,13 +40,13 @@
<!-- Overlay is not shown if collapsing series in library -->
<div v-show="!booksInSeries && audiobook && (isHovering || isSelectionMode || isMoreMenuOpen)" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
<div v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
<div class="hover:text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
</div>
</div>
<div v-show="showReadButton" class="h-full flex items-center justify-center pointer-events-none">
<div class="hover:text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="clickReadEBook">
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="clickReadEBook">
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">auto_stories</span>
</div>
</div>
@ -61,16 +63,20 @@
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
</div>
</div>
<!-- Series name overlay -->
<div v-if="booksInSeries && audiobook && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
<p class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ series }}</p>
</div>
<!-- Error widget -->
<ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0 z-10">
<div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
</div>
</ui-tooltip>
<!-- Volume number -->
<div v-if="volumeNumber && showVolumeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
</div>
@ -99,7 +105,10 @@ export default {
// Book can be passed as prop or set with setEntity()
type: Object,
default: () => null
}
},
orderBy: String,
filterBy: String,
sortingIgnorePrefix: Boolean
},
data() {
return {
@ -114,6 +123,15 @@ export default {
showCoverBg: false
}
},
watch: {
bookMount: {
handler(newVal) {
if (newVal) {
this.audiobook = newVal
}
}
}
},
computed: {
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
@ -180,6 +198,24 @@ export default {
volumeNumber() {
return this.book.volumeNumber || null
},
displayTitle() {
if (this.orderBy === 'book.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) {
return this.title.substr(4) + ', The'
}
return this.title
},
displayAuthor() {
if (this.orderBy === 'book.authorLF') return this.authorLF
return this.authorFL
},
displaySortLine() {
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._audiobook.mtimeMs)
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._audiobook.birthtimeMs)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._audiobook.addedAt)
if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this._audiobook.duration, false)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._audiobook.size)
return null
},
userProgress() {
return this.store.getters['user/getUserAudiobook'](this.audiobookId)
},
@ -322,6 +358,11 @@ export default {
isAlternativeBookshelfView() {
var constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView === constants.BookshelfView.TITLES
},
titleDisplayBottomOffset() {
if (!this.isAlternativeBookshelfView) return 0
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
return 4.25 * this.sizeMultiplier
}
},
methods: {
@ -461,8 +502,8 @@ export default {
this.$emit('select', this.audiobook)
},
play() {
this.store.commit('setStreamAudiobook', this.audiobook)
this._socket.emit('open_stream', this.audiobookId)
var eventBus = this.$eventBus || this.$nuxt.$eventBus
eventBus.$emit('play-audiobook', this.audiobookId)
},
mouseover() {
this.isHovering = true

View file

@ -59,6 +59,14 @@ export default {
{
text: 'Size',
value: 'size'
},
{
text: 'File Birthtime',
value: 'birthtimeMs'
},
{
text: 'File Modified',
value: 'mtimeMs'
}
]
}

View file

@ -89,6 +89,8 @@ export default {
this.showMenu = val
}
},
mounted() {}
mounted() {
this.currentPlaybackRate = this.playbackRate
}
}
</script>

View file

@ -51,11 +51,25 @@ export default {
}
},
methods: {
scroll(e) {
if (!e || !e.wheelDeltaY) return
if (e.wheelDeltaY > 0) {
this.volume = Math.min(1, this.volume + 0.1)
} else {
this.volume = Math.max(0, this.volume - 0.1)
}
},
mouseover() {
if (!this.isHovering) {
window.addEventListener('mousewheel', this.scroll)
}
this.isHovering = true
this.setOpen()
},
mouseleave() {
if (this.isHovering) {
window.removeEventListener('mousewheel', this.scroll)
}
this.isHovering = false
},
setOpen() {
@ -127,6 +141,11 @@ export default {
if (this.value === 0) {
this.isMute = true
}
},
beforeDestroy() {
window.removeEventListener('mousewheel', this.scroll)
document.body.removeEventListener('mousemove', this.mousemove)
document.body.removeEventListener('mouseup', this.mouseup)
}
}
</script>

View file

@ -4,9 +4,6 @@
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
<div class="absolute cover-bg" ref="coverBg" />
</div>
<!-- <div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
<div class="w-full h-full z-0" ref="coverBg" />
</div> -->
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
<a v-if="!imageFailed && showOpenNewTab && isHovering" :href="cover" @click.stop target="_blank" class="absolute bg-primary flex items-center justify-center shadow-sm rounded-full hover:scale-110 transform duration-100" :style="{ top: sizeMultiplier * 0.5 + 'rem', right: sizeMultiplier * 0.5 + 'rem', width: 2.5 * sizeMultiplier + 'rem', height: 2.5 * sizeMultiplier + 'rem' }">

View file

@ -39,20 +39,6 @@
</template>
</div>
</div>
<!-- <form @submit.prevent="submitSearchForm">
<div class="flex items-center justify-start -mx-1 py-2 mt-2">
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="searchTitle" label="Search Title" placeholder="Search" :disabled="processing" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="searchAuthor" label="Author" :disabled="processing" />
</div>
<div class="w-24 px-1">
<ui-btn type="submit" class="mt-5 w-full" :padding-x="0">Search</ui-btn>
</div>
</div>
</form> -->
</div>
</div>
<form @submit.prevent="submitSearchForm">

View file

@ -72,6 +72,10 @@
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
</ui-tooltip>
<ui-tooltip :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4">
<ui-btn v-if="isRootUser" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
</ui-tooltip>
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4">
<ui-btn v-if="isRootUser" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
</ui-tooltip>
@ -113,7 +117,8 @@ export default {
resettingProgress: false,
isScrollable: false,
savingMetadata: false,
rescanning: false
rescanning: false,
quickMatching: false
}
},
watch: {
@ -163,12 +168,41 @@ export default {
libraryId() {
return this.audiobook ? this.audiobook.libraryId : null
},
libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.libraryId) || 'google'
},
libraryScan() {
if (!this.libraryId) return null
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
}
},
methods: {
quickMatch() {
this.quickMatching = true
var matchOptions = {
provider: this.libraryProvider,
title: this.details.title,
author: this.details.author !== this.book.author ? this.details.author : null
}
this.$axios
.$post(`/api/books/${this.audiobookId}/match`, matchOptions)
.then((res) => {
this.quickMatching = false
if (res.warning) {
this.$toast.warning(res.warning)
} else if (res.updated) {
this.$toast.success('Audiobook details updated')
} else {
this.$toast.info('No updates were made')
}
})
.catch((error) => {
var errMsg = error.response ? error.response.data || '' : ''
console.error('Failed to match', error)
this.$toast.error(errMsg || 'Failed to match')
this.quickMatching = false
})
},
audiobookScanComplete(result) {
this.rescanning = false
if (!result) {

View file

@ -1,12 +1,19 @@
<template>
<div class="w-full h-full px-4 py-2 mb-12">
<div class="flex items-center py-1 mb-2">
<span v-show="showDirectoryPicker" class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="backArrowPress">arrow_back</span>
<div class="w-full h-full px-4 py-2 mb-4">
<div v-show="showDirectoryPicker" class="flex items-center py-1 mb-2">
<span class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="backArrowPress">arrow_back</span>
<p class="px-4 text-xl">{{ title }}</p>
</div>
<div v-if="!showDirectoryPicker" class="w-full h-full py-4">
<ui-text-input-with-label v-model="name" label="Library Name" />
<div class="flex flex-wrap -mx-1">
<div class="w-full md:w-2/3 px-1">
<ui-text-input-with-label v-model="name" label="Library Name" />
</div>
<div class="w-full md:w-1/3 px-1">
<ui-dropdown v-model="provider" :items="providers" label="Metadata Provider" small />
</div>
</div>
<div class="w-full py-4">
<p class="px-1 text-sm font-semibold">Folders</p>
@ -26,7 +33,17 @@
</div>
</div>
</div>
<modals-libraries-folder-chooser v-else :paths="folderPaths" @select="selectFolder" />
<div v-if="!showDirectoryPicker">
<div class="flex items-center pt-2">
<ui-toggle-switch v-if="!globalWatcherDisabled" v-model="disableWatcher" />
<ui-toggle-switch v-else disabled :value="false" />
<p class="pl-4 text-lg">Disable folder watcher for library</p>
</div>
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p>
</div>
</div>
</template>
@ -42,8 +59,10 @@ export default {
data() {
return {
name: '',
provider: '',
folders: [],
showDirectoryPicker: false
showDirectoryPicker: false,
disableWatcher: false
}
},
computed: {
@ -61,7 +80,13 @@ export default {
var newfolderpaths = this.folderPaths.join(',')
var origfolderpaths = this.library.folders.map((f) => f.fullPath).join(',')
return newfolderpaths === origfolderpaths && this.name === this.library.name
return newfolderpaths === origfolderpaths && this.name === this.library.name && this.provider === this.library.provider && this.disableWatcher === this.library.disableWatcher
},
providers() {
return this.$store.state.scanners.providers
},
globalWatcherDisabled() {
return this.$store.getters['getServerSetting']('scannerDisableWatcher')
}
},
methods: {
@ -75,7 +100,9 @@ export default {
},
init() {
this.name = this.library ? this.library.name : ''
this.provider = this.library ? this.library.provider : ''
this.folders = this.library ? this.library.folders.map((p) => ({ ...p })) : []
this.disableWatcher = this.library ? !!this.library.disableWatcher : false
this.showDirectoryPicker = false
},
selectFolder(fullPath) {
@ -100,7 +127,9 @@ export default {
}
var newLibraryPayload = {
name: this.name,
folders: this.folders
provider: this.provider,
folders: this.folders,
disableWatcher: this.disableWatcher
}
this.$emit('update:processing', true)
@ -132,7 +161,9 @@ export default {
}
var newLibraryPayload = {
name: this.name,
folders: this.folders
provider: this.provider,
folders: this.folders,
disableWatcher: this.disableWatcher
}
this.$emit('update:processing', true)

View file

@ -9,8 +9,11 @@
</svg>
<p class="text-xl font-book pl-4 hover:underline cursor-pointer" @click.stop="$emit('click', library)">{{ library.name }}</p>
<div class="flex-grow" />
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="bg" @click.stop="scan">Scan</ui-btn>
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="success" @click.stop="scan">Scan</ui-btn>
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="bg" class="ml-2" @click.stop="forceScan">Force Re-Scan</ui-btn>
<ui-btn v-show="isHovering && !libraryScan && canScan" small color="bg" class="ml-2" @click.stop="matchAll">Match Books</ui-btn>
<span v-show="isHovering && !libraryScan && showEdit && canEdit" class="material-icons text-xl text-gray-300 hover:text-gray-50 ml-4 cursor-pointer" @click.stop="editClick">edit</span>
<span v-show="!libraryScan && isHovering && showEdit && canDelete && !isDeleting" class="material-icons text-xl text-gray-300 ml-3" :class="isMain ? 'text-opacity-5 cursor-not-allowed' : 'hover:text-gray-50 cursor-pointer'" @click.stop="deleteClick">delete</span>
<div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin">
@ -59,6 +62,18 @@ export default {
}
},
methods: {
matchAll() {
this.$axios
.$post(`/api/libraries/${this.library.id}/matchbooks`)
.then(() => {
console.log('Starting scan for matches')
})
.catch((error) => {
console.error('Failed', error)
var errorMsg = err.response ? err.response.data : ''
this.$toast.error(errorMsg || 'Match all failed')
})
},
editClick() {
this.$emit('edit', this.library)
},

View file

@ -16,6 +16,8 @@
<modals-edit-library-modal v-model="showLibraryModal" :library="selectedLibrary" />
<p class="text-xs mt-4 text-gray-200">*<strong>Force Re-Scan</strong> will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.</p>
<p class="text-xs mt-4 text-gray-200">**<strong>Match Books</strong> will attempt to match books in library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.</p>
</div>
</template>

View file

@ -0,0 +1,60 @@
<template>
<div class="w-full my-4" @mousedown.prevent @mouseup.prevent>
<div class="w-full bg-primary px-6 py-1 flex items-center cursor-pointer" @click.stop="clickBar">
<p class="pr-4">{{ title }}</p>
<span class="bg-black-400 rounded-xl py-0.5 px-2 text-sm font-mono">{{ files.length }}</span>
<div class="flex-grow" />
<div class="cursor-pointer h-9 w-9 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="expand ? 'transform rotate-180' : ''">
<span class="material-icons text-3xl">expand_more</span>
</div>
</div>
<transition name="slide">
<div class="w-full" v-show="expand">
<table class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left">Filename</th>
<th class="text-left">Size</th>
<th class="text-left">Type</th>
</tr>
<template v-for="file in files">
<tr :key="file.path">
<td class="font-book pl-2">
{{ file.name }}
</td>
<td class="font-mono">
{{ $bytesPretty(file.size) }}
</td>
<td class="font-book">
{{ file.filetype }}
</td>
</tr>
</template>
</table>
</div>
</transition>
</div>
</template>
<script>
export default {
props: {
title: String,
files: {
type: Array,
default: () => []
}
},
data() {
return {
expand: false
}
},
computed: {},
methods: {
clickBar() {
this.expand = !this.expand
}
},
mounted() {}
}
</script>

View file

@ -133,8 +133,7 @@ export default {
this.isHovering = false
},
playClick() {
this.$store.commit('setStreamAudiobook', this.book)
this.$root.socket.emit('open_stream', this.book.id)
this.$eventBus.$emit('play-audiobook', this.book.id)
},
clickEdit() {
this.$emit('edit', this.book)

View file

@ -1,7 +1,7 @@
<template>
<div class="relative w-full" v-click-outside="clickOutsideObj">
<p class="text-sm font-semibold">{{ label }}</p>
<button type="button" :disabled="disabled" class="relative w-full border border-gray-500 rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm cursor-pointer bg-primary" :class="small ? 'h-9' : 'h-10'" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<button type="button" :disabled="disabled" class="relative w-full border border-gray-600 rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm cursor-pointer bg-primary" :class="small ? 'h-9' : 'h-10'" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="flex items-center">
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText }}</span>
</span>

View file

@ -1,9 +1,9 @@
<template>
<div class="w-full" :class="disabled ? 'cursor-not-allowed' : ''">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<div class="w-full">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
<div ref="wrapper" class="relative">
<form @submit.prevent="submitForm">
<div ref="inputWrapper" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'bg-bg pointer-events-none text-gray-400' : 'bg-primary'">
<div ref="inputWrapper" class="input-wrapper flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'">
<input ref="input" v-model="textInput" :disabled="disabled" :readonly="!editable" class="h-full w-full bg-transparent focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
</div>
</form>

View file

@ -1,6 +1,6 @@
<template>
<div class="w-full">
<p class="px-1 text-sm font-semibold">{{ label }}</p>
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
<div ref="wrapper" class="relative">
<form @submit.prevent="submitForm">
<div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded-md px-2 py-1 cursor-text" :class="disabled ? 'bg-black-300' : 'bg-primary'" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>

View file

@ -80,7 +80,7 @@ input {
border-style: inherit !important;
}
input:read-only {
color: #aaa;
color: #bbb;
background-color: #444;
}
</style>

View file

@ -3,7 +3,7 @@
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
</p>
<ui-text-input v-model="inputValue" :disabled="disabled" :type="type" class="w-full" />
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :type="type" class="w-full" />
</div>
</template>
@ -32,7 +32,13 @@ export default {
}
}
},
methods: {},
methods: {
blur() {
if (this.$refs.input && this.$refs.input.blur) {
this.$refs.input.blur()
}
}
},
mounted() {}
}
</script>

View file

@ -0,0 +1,33 @@
<template>
<div class="w-full bg-opacity-5 border border-opacity-60 rounded-lg flex items-center relative py-4 pl-16" :class="wrapperClass">
<div class="absolute top-0 left-4 h-full flex items-center">
<span class="material-icons-outlined text-2xl">{{ icon }}</span>
</div>
<slot />
</div>
</template>
<script>
export default {
props: {
type: {
type: String,
default: 'error'
}
},
data() {
return {}
},
computed: {
icon() {
if (this.type === 'error' || this.type === 'warning') return 'report'
return 'info'
},
wrapperClass() {
return `bg-${this.type} border-${this.type} text-${this.type}`
}
},
methods: {},
mounted() {}
}
</script>

View file

@ -43,6 +43,9 @@ export default {
computed: {
user() {
return this.$store.state.user.user
},
isCasting() {
return this.$store.state.globals.isCasting
}
},
methods: {
@ -99,7 +102,6 @@ export default {
console.log('Init Payload', payload)
if (payload.stream) {
if (this.$refs.streamContainer) {
this.$store.commit('setStream', payload.stream)
this.$refs.streamContainer.streamOpen(payload.stream)
} else {
console.warn('Stream Container not mounted')
@ -110,7 +112,12 @@ export default {
this.$store.commit('user/setSettings', payload.user.settings)
}
if (payload.serverSettings) {
this.$store.commit('settings/setServerSettings', payload.serverSettings)
this.$store.commit('setServerSettings', payload.serverSettings)
if (payload.serverSettings.chromecastEnabled) {
console.log('Chromecast enabled import script')
require('@/plugins/chromecast.js').default(this)
}
}
if (payload.SSOSettings) {
this.$store.commit('settings/setSSOSettings', payload.SSOSettings)
@ -208,7 +215,7 @@ export default {
scanComplete(data) {
console.log('Scan complete received', data)
var message = `Scan "${data.name}" complete!`
var message = `${data.type === 'match' ? 'Match' : 'Scan'} "${data.name}" complete!`
if (data.results) {
var scanResultMsgs = []
var results = data.results
@ -219,7 +226,7 @@ export default {
if (!scanResultMsgs.length) message += '\nEverything was up to date'
else message += '\n' + scanResultMsgs.join('\n')
} else {
message = `Scan "${data.name}" was canceled`
message = `${data.type === 'match' ? 'Match' : 'Scan'} "${data.name}" was canceled`
}
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
@ -235,7 +242,7 @@ export default {
this.$root.socket.emit('cancel_scan', id)
},
scanStart(data) {
data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, position: 'bottom-center', onClose: () => this.onScanToastCancel(data.id) })
data.toastId = this.$toast(`${data.type === 'match' ? 'Matching' : 'Scanning'} "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, position: 'bottom-center', onClose: () => this.onScanToastCancel(data.id) })
this.$store.commit('scanners/addUpdate', data)
},
scanProgress(data) {
@ -514,6 +521,7 @@ export default {
this.resize()
window.addEventListener('resize', this.resize)
window.addEventListener('keydown', this.keyDown)
this.$store.dispatch('libraries/load')
// If experimental features set in local storage

View file

@ -55,6 +55,11 @@ export default {
bookshelfView: this.bookshelfView
}
if (this.entityName === 'series-books') props.showVolumeNumber = true
if (this.entityName === 'books') {
props.filterBy = this.filterBy
props.orderBy = this.orderBy
props.sortingIgnorePrefix = !!this.sortingIgnorePrefix
}
var _this = this
var instance = new ComponentClass({

View file

@ -0,0 +1,229 @@
import Path from 'path'
export default {
data() {
return {
uploadHelpers: {
getBooksFromDrop: this.getBooksFromDataTransferItems,
getBooksFromPicker: this.getBooksFromFileList
}
}
},
methods: {
checkFileType(filename) {
var ext = Path.extname(filename)
if (!ext) return false
if (ext.startsWith('.')) ext = ext.slice(1)
ext = ext.toLowerCase()
for (const filetype in this.$constants.SupportedFileTypes) {
if (this.$constants.SupportedFileTypes[filetype].includes(ext)) {
return filetype
}
}
return false
},
filterAudiobookFiles(files) {
var validBookFiles = []
var validOtherFiles = []
var ignoredFiles = []
files.forEach((file) => {
var filetype = this.checkFileType(file.name)
if (!filetype) ignoredFiles.push(file)
else {
file.filetype = filetype
if (filetype === 'audio' || filetype === 'ebook') validBookFiles.push(file)
else validOtherFiles.push(file)
}
})
return {
bookFiles: validBookFiles,
otherFiles: validOtherFiles,
ignoredFiles
}
},
audiobookFromItems(items) {
var { bookFiles, otherFiles, ignoredFiles } = this.filterAudiobookFiles(items)
if (!bookFiles.length) {
ignoredFiles = ignoredFiles.concat(otherFiles)
otherFiles = []
}
return [
{
bookFiles,
otherFiles,
ignoredFiles
}
]
},
traverseForAudiobook(folder, depth = 1) {
if (folder.items.some((f) => f.isDirectory)) {
var audiobooks = []
folder.items.forEach((file) => {
if (file.isDirectory) {
var audiobookResults = this.traverseForAudiobook(file, ++depth)
audiobooks = audiobooks.concat(audiobookResults)
}
})
return audiobooks
} else {
return this.audiobookFromItems(folder.items)
}
},
fileTreeToAudiobooks(filetree) {
// Has directores - Is Multi Book Drop
if (filetree.some((f) => f.isDirectory)) {
var ignoredFilesInRoot = filetree.filter((f) => !f.isDirectory)
if (ignoredFilesInRoot.length) filetree = filetree.filter((f) => f.isDirectory)
var audiobookResults = this.traverseForAudiobook({ items: filetree })
return {
audiobooks: audiobookResults,
ignoredFiles: ignoredFilesInRoot
}
} else {
// Single Book drop
return {
audiobooks: this.audiobookFromItems(filetree),
ignoredFiles: []
}
}
},
getFilesDropped(dataTransferItems) {
var treemap = {
path: '/',
items: []
}
function traverseFileTreePromise(item, currtreemap) {
return new Promise((resolve) => {
if (item.isFile) {
item.file((file) => {
file.filepath = currtreemap.path + file.name //save full path
currtreemap.items.push(file)
resolve(file)
})
} else if (item.isDirectory) {
let dirReader = item.createReader()
currtreemap.items.push({
isDirectory: true,
dirname: item.name,
path: currtreemap.path + item.name + '/',
items: []
})
var newtreemap = currtreemap.items[currtreemap.items.length - 1]
dirReader.readEntries((entries) => {
let entriesPromises = []
for (let entr of entries) entriesPromises.push(traverseFileTreePromise(entr, newtreemap))
resolve(Promise.all(entriesPromises))
})
}
})
}
return new Promise((resolve, reject) => {
let entriesPromises = []
for (let it of dataTransferItems) {
var filetree = traverseFileTreePromise(it.webkitGetAsEntry(), treemap)
entriesPromises.push(filetree)
}
Promise.all(entriesPromises).then(() => {
resolve(treemap.items)
})
})
},
cleanBook(book, index) {
var audiobook = {
index,
title: '',
author: '',
series: '',
...book
}
var firstBookFile = book.bookFiles[0]
if (!firstBookFile.filepath) return audiobook // No path
var firstBookPath = Path.dirname(firstBookFile.filepath)
var dirs = firstBookPath.split('/').filter(d => !!d && d !== '.')
if (dirs.length) {
audiobook.title = dirs.pop()
if (dirs.length > 1) {
audiobook.series = dirs.pop()
}
if (dirs.length) {
audiobook.author = dirs.pop()
}
}
return audiobook
},
async getBooksFromDataTransferItems(items) {
var files = await this.getFilesDropped(items)
if (!files || !files.length) return { error: 'No files found ' }
var audiobooksData = this.fileTreeToAudiobooks(files)
if (!audiobooksData.audiobooks.length && !audiobooksData.ignoredFiles.length) {
return { error: 'Invalid file drop' }
}
var ignoredFiles = audiobooksData.ignoredFiles
var index = 1
var books = audiobooksData.audiobooks.filter((ab) => {
if (!ab.bookFiles.length) {
if (ab.otherFiles.length) ignoredFiles = ignoredFiles.concat(ab.otherFiles)
if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles)
}
return ab.bookFiles.length
}).map(ab => this.cleanBook(ab, index++))
return {
books,
ignoredFiles
}
},
getBooksFromFileList(filelist) {
var ignoredFiles = []
var otherFiles = []
var bookMap = {}
filelist.forEach((file) => {
var filetype = this.checkFileType(file.name)
if (!filetype) ignoredFiles.push(file)
else {
file.filetype = filetype
if (file.webkitRelativePath) file.filepath = file.webkitRelativePath
if (filetype === 'audio' || filetype === 'ebook') {
var dir = file.filepath ? Path.dirname(file.filepath) : ''
if (!bookMap[dir]) {
bookMap[dir] = {
path: dir,
ignoredFiles: [],
bookFiles: [],
otherFiles: []
}
}
bookMap[dir].bookFiles.push(file)
} else {
otherFiles.push(file)
}
}
})
otherFiles.forEach((file) => {
var dir = Path.dirname(file.filepath)
var findBook = Object.values(bookMap).find(b => dir.startsWith(b.path))
if (findBook) {
bookMap[dir].otherFiles.push(file)
} else {
ignoredFiles.push(file)
}
})
var index = 1
var books = Object.values(bookMap).map(ab => this.cleanBook(ab, index++))
return {
books,
ignoredFiles: ignoredFiles
}
},
}
}

View file

@ -7,7 +7,7 @@ module.exports = {
dev: process.env.NODE_ENV !== 'production',
env: {
serverUrl: process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3333',
// serverUrl: '',
chromecastReceiver: 'FD1F76C5',
baseUrl: process.env.BASE_URL || 'http://0.0.0.0'
},
// rootDir: process.env.NODE_ENV !== 'production' ? 'client/' : '',
@ -30,13 +30,12 @@ module.exports = {
],
script: [
{
src: '//cdn.jsdelivr.net/npm/sortablejs@1.8.4/Sortable.min.js'
src: '/libs/sortable.js'
}
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Ubuntu+Mono&family=Source+Sans+Pro:wght@300;400;600' },
// { rel: 'stylesheet', href: 'https://fonts.googleapis.com/icon?family=Material+Icons' }
]
},

View file

@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "1.6.66",
"version": "1.7.1",
"description": "Audiobook manager and player",
"main": "index.js",
"scripts": {

View file

@ -5,7 +5,20 @@
<div class="w-full flex justify-center md:block md:w-52" style="min-width: 208px">
<div class="relative" style="height: fit-content">
<covers-book-cover :audiobook="audiobook" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<!-- Book Progress Bar -->
<div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm z-10" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 208 * progressPercent + 'px' }"></div>
<!-- Book Cover Overlay -->
<div class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-30 opacity-0 hover:opacity-100 transition-opacity" @mousedown.prevent @mouseup.prevent>
<div v-show="showPlayButton && !streaming" class="h-full flex items-center justify-center pointer-events-none">
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="startStream">
<span class="material-icons text-4xl">play_circle_filled</span>
</div>
</div>
<span class="absolute bottom-2.5 right-2.5 z-10 material-icons text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200" @click="showEditCover">edit</span>
</div>
</div>
</div>
<div class="flex-grow px-2 py-6 md:py-0 md:px-10">
@ -378,6 +391,10 @@ export default {
}
},
methods: {
showEditCover() {
this.$store.commit('setBookshelfBookIds', [])
this.$store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'cover' })
},
openEbook() {
this.$store.commit('showEReader', this.audiobook)
},
@ -411,8 +428,7 @@ export default {
})
},
startStream() {
this.$store.commit('setStreamAudiobook', this.audiobook)
this.$root.socket.emit('open_stream', this.audiobook.id)
this.$eventBus.$emit('play-audiobook', this.audiobook.id)
},
editClick() {
this.$store.commit('setBookshelfBookIds', [])

View file

@ -1,31 +1,58 @@
<template>
<div ref="page" id="page-wrapper" class="page px-6 pt-6 pb-52 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
<!-- <div class="flex justify-center max-w-3xl mx-auto border border-black-300 p-6 mb-2">
<div class="flex-grow">
<p class="text-xl mb-4">Batch edit {{ audiobooks.length }} books</p>
<div class="border border-white border-opacity-10 max-w-7xl mx-auto mb-10 mt-5">
<div class="flex items-center px-4 py-4 cursor-pointer" @click="openMapOptions = !openMapOptions" @mousedown.prevent @mouseup.prevent>
<span class="material-icons">{{ openMapOptions ? 'expand_less' : 'expand_more' }}</span>
<div class="flex items-center px-1">
<ui-checkbox v-model="selectedBatchUsage.author" />
<ui-text-input-with-label v-model="batchBook.author" :disabled="!selectedBatchUsage.author" label="Author" class="mb-4 ml-4" />
</div>
<div class="flex items-center px-1">
<ui-checkbox v-model="selectedBatchUsage.series" />
<ui-input-dropdown v-model="batchBook.series" :disabled="!selectedBatchUsage.series" label="Series" :items="seriesItems" @input="seriesChanged" @newItem="newSeriesItem" class="mb-4 ml-4" />
</div>
<div class="flex items-center px-1">
<ui-checkbox v-model="selectedBatchUsage.genres" />
<ui-multi-select v-model="batchBook.genres" :disabled="!selectedBatchUsage.genres" label="Genres" :items="genreItems" @newItem="newGenreItem" @removedItem="removedGenreItem" class="mb-4 ml-4" />
</div>
<div class="flex items-center px-1">
<ui-checkbox v-model="selectedBatchUsage.narrator" />
<ui-text-input-with-label v-model="batchBook.narrator" :disabled="!selectedBatchUsage.narrator" label="Narrator" class="mb-4 ml-4" />
</div>
<div class="flex items-center px-1">
<ui-checkbox v-model="selectedBatchUsage.tags" />
<ui-multi-select v-model="batchTags" label="Tags" :disabled="!selectedBatchUsage.tags" :items="tagItems" @newItem="newTagItem" @removedItem="removedTagItem" class="mb-4 ml-4" />
</div>
<p class="ml-4 text-gray-200 text-lg">Map details</p>
</div>
</div> -->
<div class="overflow-hidden">
<transition name="slide">
<div v-if="openMapOptions" class="flex flex-wrap">
<div class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.subtitle" />
<ui-text-input-with-label ref="subtitleInput" v-model="batchDetails.subtitle" :disabled="!selectedBatchUsage.subtitle" label="Subtitle" class="mb-4 ml-4" />
</div>
<div class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.author" />
<ui-text-input-with-label ref="authorInput" v-model="batchDetails.author" :disabled="!selectedBatchUsage.author" label="Author" class="mb-4 ml-4" />
</div>
<div class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.publishYear" />
<ui-text-input-with-label ref="publishYearInput" v-model="batchDetails.publishYear" :disabled="!selectedBatchUsage.publishYear" label="Publish Year" class="mb-4 ml-4" />
</div>
<div class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.series" />
<ui-input-dropdown ref="seriesDropdown" v-model="batchDetails.series" :disabled="!selectedBatchUsage.series" label="Series" :items="seriesItems" @input="seriesChanged" @newItem="newSeriesItem" class="mb-4 ml-4" />
</div>
<div class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.genres" />
<ui-multi-select ref="genresSelect" v-model="batchDetails.genres" :disabled="!selectedBatchUsage.genres" label="Genres" :items="genreItems" @newItem="newGenreItem" @removedItem="removedGenreItem" class="mb-4 ml-4" />
</div>
<div class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.tags" />
<ui-multi-select ref="tagsSelect" v-model="batchDetails.tags" label="Tags" :disabled="!selectedBatchUsage.tags" :items="tagItems" @newItem="newTagItem" @removedItem="removedTagItem" class="mb-4 ml-4" />
</div>
<div class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.narrator" />
<ui-text-input-with-label ref="narratorInput" v-model="batchDetails.narrator" :disabled="!selectedBatchUsage.narrator" label="Narrator" class="mb-4 ml-4" />
</div>
<div class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.publisher" />
<ui-text-input-with-label ref="publisherInput" v-model="batchDetails.publisher" :disabled="!selectedBatchUsage.publisher" label="Publisher" class="mb-4 ml-4" />
</div>
<div class="flex items-center px-4 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.language" />
<ui-text-input-with-label ref="languageInput" v-model="batchDetails.language" :disabled="!selectedBatchUsage.language" label="Language" class="mb-4 ml-4" />
</div>
<div class="w-full flex items-center justify-end p-4">
<ui-btn color="success" :disabled="!hasSelectedBatchUsage" :padding-x="8" small class="text-base" :loading="isProcessing" @click="mapBatchDetails">Apply</ui-btn>
</div>
</div>
</transition>
</div>
</div>
<div class="flex justify-center flex-wrap">
<template v-for="audiobook in audiobookCopies">
@ -130,19 +157,29 @@ export default {
newSeriesItems: [],
newTagItems: [],
newGenreItems: [],
batchBook: {
batchDetails: {
subtitle: null,
author: null,
genres: [],
publishYear: null,
series: null,
narrator: null
genres: [],
tags: [],
narrator: null,
publisher: null,
language: null
},
selectedBatchUsage: {
subtitle: false,
author: false,
genres: false,
publishYear: false,
series: false,
narrator: false
genres: false,
tags: false,
narrator: false,
publisher: false,
language: false
},
batchTags: []
openMapOptions: false
}
},
computed: {
@ -178,9 +215,46 @@ export default {
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
hasSelectedBatchUsage() {
return Object.values(this.selectedBatchUsage).some((b) => !!b)
}
},
methods: {
blurBatchForm() {
if (this.$refs.seriesDropdown && this.$refs.seriesDropdown.isFocused) {
this.$refs.seriesDropdown.blur()
}
if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) {
this.$refs.genresSelect.forceBlur()
}
if (this.$refs.tagsSelect && this.$refs.tagsSelect.isFocused) {
this.$refs.tagsSelect.forceBlur()
}
for (const key in this.batchDetails) {
if (this.$refs[`${key}Input`] && this.$refs[`${key}Input`].blur) {
this.$refs[`${key}Input`].blur()
}
}
},
mapBatchDetails() {
this.blurBatchForm()
this.audiobookCopies = this.audiobookCopies.map((ab) => {
for (const key in this.selectedBatchUsage) {
if (this.selectedBatchUsage[key]) {
if (key === 'tags') {
ab.tags = this.batchDetails.tags
} else {
ab.book[key] = this.batchDetails[key]
}
}
}
return ab
})
this.$toast.success('Details mapped')
},
newTagItem(item) {
if (item && !this.newTagItems.includes(item)) {
this.newTagItems.push(item)
@ -306,7 +380,7 @@ export default {
},
applyBatchUpdates() {
this.audiobookCopies = this.audiobookCopies.map((ab) => {
if (this.batchBook.series) ab.book.series = this.batchBook.series
if (this.batchDetails.series) ab.book.series = this.batchDetails.series
})
}
},
@ -316,3 +390,15 @@ export default {
}
</script>
<style>
.slide-enter-active,
.slide-leave-active {
transition: transform 0.2s ease;
}
.slide-enter,
.slide-leave-to {
transform: translateY(-100%);
transition: all 150ms ease-in 0s;
}
</style>

View file

@ -123,8 +123,7 @@ export default {
clickPlay() {
var nextBookNotRead = this.playableBooks.find((pb) => !this.userAudiobooks[pb.id] || !this.userAudiobooks[pb.id].isRead)
if (nextBookNotRead) {
this.$store.commit('setStreamAudiobook', nextBookNotRead)
this.$root.socket.emit('open_stream', nextBookNotRead.id)
this.$eventBus.$emit('play-audiobook', nextBookNotRead.id)
}
}
},

View file

@ -57,16 +57,6 @@ export default {
this.$store.commit('setDeveloperMode', value)
this.$toast.info(`Developer Mode ${value ? 'Enabled' : 'Disabled'}`)
}
// saveMetadataComplete(result) {
// this.savingMetadata = false
// if (!result) return
// this.$toast.success(`Metadata saved for ${result.success} audiobooks`)
// },
// saveMetadataFiles() {
// this.savingMetadata = true
// this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
// this.$root.socket.emit('save_metadata')
// }
},
mounted() {}
}

View file

@ -8,41 +8,76 @@
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="storeCoversInAudiobookDir" :disabled="updatingServerSettings" @input="updateCoverStorageDestination" />
<ui-tooltip :text="coverDestinationTooltip">
<p class="pl-4 text-lg">Store covers with audiobook <span class="material-icons icon-text">info_outlined</span></p>
<ui-toggle-switch v-model="newServerSettings.storeCoverWithBook" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeCoverWithBook', val)" />
<ui-tooltip :text="tooltips.storeCoverWithBook">
<p class="pl-4 text-lg">
Store covers with book
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.storeMetadataWithBook" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('storeMetadataWithBook', val)" />
<ui-tooltip :text="tooltips.storeMetadataWithBook">
<p class="pl-4 text-lg">
Store metadata with book
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="useSquareBookCovers" :disabled="updatingServerSettings" @input="updateBookCoverAspectRatio" />
<ui-tooltip :text="coverAspectRatioTooltip">
<p class="pl-4 text-lg">Use square book covers <span class="material-icons icon-text">info_outlined</span></p>
<ui-tooltip :text="tooltips.coverAspectRatio">
<p class="pl-4 text-lg">
Use square book covers
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
<ui-tooltip :text="bookshelfViewTooltip">
<p class="pl-4 text-lg">Use alternative library bookshelf view <span class="material-icons icon-text">info_outlined</span></p>
<ui-tooltip :text="tooltips.bookshelfView">
<p class="pl-4 text-lg">
Use alternative library bookshelf view
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.sortingIgnorePrefix" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('sortingIgnorePrefix', val)" />
<p class="pl-4 text-lg">Ignore prefix "The" when sorting title and series</p>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
<p class="pl-4 text-lg">Enable Chromecast</p>
</div>
<div class="flex items-center mb-2 mt-8">
<h1 class="text-xl">Scanner Settings</h1>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" small :disabled="updatingServerSettings" @input="updateScannerParseSubtitle" />
<ui-tooltip :text="parseSubtitleTooltip">
<p class="pl-4 text-lg">Scanner parse subtitles <span class="material-icons icon-text">info_outlined</span></p>
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerParseSubtitle', val)" />
<ui-tooltip :text="tooltips.scannerParseSubtitle">
<p class="pl-4 text-lg">
Scanner parse subtitles
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="updateScannerFindCovers" />
<ui-tooltip :text="scannerFindCoversTooltip">
<p class="pl-4 text-lg">Scanner find covers <span class="material-icons icon-text">info_outlined</span></p>
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerFindCovers', val)" />
<ui-tooltip :text="tooltips.scannerFindCovers">
<p class="pl-4 text-lg">
Scanner find covers
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
<div class="flex-grow" />
</div>
@ -51,16 +86,32 @@
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="updateScannerPreferAudioMeta" />
<ui-tooltip :text="scannerPreferAudioMetaTooltip">
<p class="pl-4 text-lg">Scanner prefer audio metadata <span class="material-icons icon-text">info_outlined</span></p>
<ui-toggle-switch v-model="newServerSettings.scannerPreferAudioMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferAudioMetadata', val)" />
<ui-tooltip :text="tooltips.scannerPreferAudioMetadata">
<p class="pl-4 text-lg">
Scanner prefer audio metadata
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="updateScannerPreferOpfMeta" />
<ui-tooltip :text="scannerPreferOpfMetaTooltip">
<p class="pl-4 text-lg">Scanner prefer OPF metadata <span class="material-icons icon-text">info_outlined</span></p>
<ui-toggle-switch v-model="newServerSettings.scannerPreferOpfMetadata" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerPreferOpfMetadata', val)" />
<ui-tooltip :text="tooltips.scannerPreferOpfMetadata">
<p class="pl-4 text-lg">
Scanner prefer OPF metadata
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-toggle-switch v-model="newServerSettings.scannerDisableWatcher" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerDisableWatcher', val)" />
<ui-tooltip :text="tooltips.scannerDisableWatcher">
<p class="pl-4 text-lg">
Disable Watcher
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
</div>
@ -71,7 +122,10 @@
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block mr-2" :loading="isPurgingCache" @click="purgeCache">Purge Cache</ui-btn>
<ui-btn color="bg" small :padding-x="4" class="hidden lg:block" :loading="isResettingAudiobooks" @click="resetAudiobooks">Remove All Audiobooks</ui-btn>
<div class="flex-grow" />
<p class="pr-2 text-sm font-book text-yellow-400">Report bugs, request features, provide feedback, and contribute on <a class="underline" href="https://github.com/advplyr/audiobookshelf" target="_blank">github</a>.</p>
<p class="pr-2 text-sm font-book text-yellow-400">
Report bugs, request features, and contribute on
<a class="underline" href="https://github.com/advplyr/audiobookshelf" target="_blank">github</a>
</p>
<a href="https://github.com/advplyr/audiobookshelf" target="_blank" class="text-white hover:text-gray-200 hover:scale-150 hover:rotate-6 transform duration-500">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="24" height="24" viewBox="0 0 24 24">
<path
@ -79,6 +133,25 @@
/>
</svg>
</a>
<p class="pl-4 pr-2 text-sm font-book text-yellow-400">
Join us on
<a class="underline" href="https://discord.gg/pJsjuNCKRq" target="_blank">discord</a>
</p>
<a href="https://discord.gg/pJsjuNCKRq" target="_blank" class="text-white hover:text-gray-200 hover:scale-150 hover:rotate-6 transform duration-500">
<svg width="31" height="24" viewBox="0 0 71 55" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<path
d="M60.1045 4.8978C55.5792 2.8214 50.7265 1.2916 45.6527 0.41542C45.5603 0.39851 45.468 0.440769 45.4204 0.525289C44.7963 1.6353 44.105 3.0834 43.6209 4.2216C38.1637 3.4046 32.7345 3.4046 27.3892 4.2216C26.905 3.0581 26.1886 1.6353 25.5617 0.525289C25.5141 0.443589 25.4218 0.40133 25.3294 0.41542C20.2584 1.2888 15.4057 2.8186 10.8776 4.8978C10.8384 4.9147 10.8048 4.9429 10.7825 4.9795C1.57795 18.7309 -0.943561 32.1443 0.293408 45.3914C0.299005 45.4562 0.335386 45.5182 0.385761 45.5576C6.45866 50.0174 12.3413 52.7249 18.1147 54.5195C18.2071 54.5477 18.305 54.5139 18.3638 54.4378C19.7295 52.5728 20.9469 50.6063 21.9907 48.5383C22.0523 48.4172 21.9935 48.2735 21.8676 48.2256C19.9366 47.4931 18.0979 46.6 16.3292 45.5858C16.1893 45.5041 16.1781 45.304 16.3068 45.2082C16.679 44.9293 17.0513 44.6391 17.4067 44.3461C17.471 44.2926 17.5606 44.2813 17.6362 44.3151C29.2558 49.6202 41.8354 49.6202 53.3179 44.3151C53.3935 44.2785 53.4831 44.2898 53.5502 44.3433C53.9057 44.6363 54.2779 44.9293 54.6529 45.2082C54.7816 45.304 54.7732 45.5041 54.6333 45.5858C52.8646 46.6197 51.0259 47.4931 49.0921 48.2228C48.9662 48.2707 48.9102 48.4172 48.9718 48.5383C50.038 50.6034 51.2554 52.5699 52.5959 54.435C52.6519 54.5139 52.7526 54.5477 52.845 54.5195C58.6464 52.7249 64.529 50.0174 70.6019 45.5576C70.6551 45.5182 70.6887 45.459 70.6943 45.3942C72.1747 30.0791 68.2147 16.7757 60.1968 4.9823C60.1772 4.9429 60.1437 4.9147 60.1045 4.8978ZM23.7259 37.3253C20.2276 37.3253 17.3451 34.1136 17.3451 30.1693C17.3451 26.225 20.1717 23.0133 23.7259 23.0133C27.308 23.0133 30.1626 26.2532 30.1066 30.1693C30.1066 34.1136 27.28 37.3253 23.7259 37.3253ZM47.3178 37.3253C43.8196 37.3253 40.9371 34.1136 40.9371 30.1693C40.9371 26.225 43.7636 23.0133 47.3178 23.0133C50.9 23.0133 53.7545 26.2532 53.6986 30.1693C53.6986 34.1136 50.9 37.3253 47.3178 37.3253Z"
fill="#ffffff"
/>
</g>
<defs>
<clipPath id="clip0">
<rect width="71" height="55" fill="white" />
</clipPath>
</defs>
</svg>
</a>
</div>
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
@ -89,13 +162,16 @@
<div class="flex items-center">
<ui-toggle-switch v-model="showExperimentalFeatures" />
<ui-tooltip :text="experimentalFeaturesTooltip">
<p class="pl-4 text-lg">Experimental Features <span class="material-icons icon-text">info_outlined</span></p>
<p class="pl-4 text-lg">
Experimental Features
<span class="material-icons icon-text">info_outlined</span>
</p>
</ui-tooltip>
</div>
</div>
<!-- <div class="hidden md:block">
<a href="https://github.com/advplyr/audiobookshelf/discussions/75#discussion-3604812" target="_blank" class="text-blue-500 hover:text-blue-300 underline">Join the discussion</a>
</div> -->
</div>-->
</div>
</div>
</div>
@ -106,12 +182,22 @@ export default {
data() {
return {
isResettingAudiobooks: false,
storeCoversInAudiobookDir: false,
updatingServerSettings: false,
useSquareBookCovers: false,
useAlternativeBookshelfView: false,
isPurgingCache: false,
newServerSettings: {}
newServerSettings: {},
tooltips: {
scannerDisableWatcher: 'Disables the automatic adding/updating of audiobooks when file changes are detected. *Requires server restart',
scannerPreferOpfMetadata: 'OPF file metadata will be used for book details over folder names',
scannerPreferAudioMetadata: 'Audio file ID3 meta tags will be used for book details over folder names',
scannerParseSubtitle: 'Extract subtitles from audiobook folder names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"',
scannerFindCovers: 'If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time',
bookshelfView: 'Alternative bookshelf view that shows title & author under book covers',
storeCoverWithBook: 'By default covers are stored in /metadata/books, enabling this setting will store covers in the books folder. Only one file named "cover" will be kept',
storeMetadataWithBook: 'By default metadata files are stored in /metadata/books, enabling this setting will store metadata files in the books folder. Uses .abs file extension',
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers'
}
}
},
watch: {
@ -123,35 +209,11 @@ export default {
}
},
computed: {
serverSettings() {
return this.$store.state.settings.serverSettings
},
scannerPreferAudioMetaTooltip() {
return 'Audio file ID3 meta tags will be used for book details over folder names'
},
scannerPreferOpfMetaTooltip() {
return 'OPF file metadata will be used for book details over folder names'
},
saveMetadataTooltip() {
return 'This will write a "metadata.nfo" file in all of your audiobook directories.'
},
experimentalFeaturesTooltip() {
return 'Features in development that could use your feedback and help testing.'
},
parseSubtitleTooltip() {
return 'Extract subtitles from audiobook directory names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"'
},
coverDestinationTooltip() {
return 'By default covers are stored in /metadata/books, enabling this setting will store covers inside your audiobooks directory. Only one file named "cover" will be kept.'
},
scannerFindCoversTooltip() {
return 'If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time'
},
coverAspectRatioTooltip() {
return 'Prefer to use square covers over standard 1.6:1 book covers'
},
bookshelfViewTooltip() {
return 'Alternative bookshelf view that shows title & author under book covers'
serverSettings() {
return this.$store.state.serverSettings
},
providers() {
return this.$store.state.scanners.providers
@ -166,37 +228,14 @@ export default {
}
},
methods: {
updateScannerFindCovers(val) {
this.updateServerSettings({
scannerFindCovers: !!val
})
updateEnableChromecast(val) {
this.updateServerSettings({ enableChromecast: val })
},
updateScannerCoverProvider(val) {
this.updateServerSettings({
scannerCoverProvider: val
})
},
updateCoverStorageDestination(val) {
this.newServerSettings.coverDestination = val ? this.$constants.CoverDestination.AUDIOBOOK : this.$constants.CoverDestination.METADATA
this.updateServerSettings({
coverDestination: this.newServerSettings.coverDestination
})
},
updateScannerParseSubtitle(val) {
this.updateServerSettings({
scannerParseSubtitle: !!val
})
},
updateScannerPreferAudioMeta(val) {
this.updateServerSettings({
scannerPreferAudioMetadata: !!val
})
},
updateScannerPreferOpfMeta(val) {
this.updateServerSettings({
scannerPreferOpfMetadata: !!val
})
},
updateBookCoverAspectRatio(val) {
this.updateServerSettings({
coverAspectRatio: val ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD
@ -207,6 +246,11 @@ export default {
bookshelfView: val ? this.$constants.BookshelfView.TITLES : this.$constants.BookshelfView.STANDARD
})
},
updateSettingsKey(key, val) {
this.updateServerSettings({
[key]: val
})
},
updateServerSettings(payload) {
this.updatingServerSettings = true
this.$store
@ -223,8 +267,6 @@ export default {
initServerSettings() {
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
this.useSquareBookCovers = this.newServerSettings.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
this.useAlternativeBookshelfView = this.newServerSettings.bookshelfView === this.$constants.BookshelfView.TITLES

View file

@ -1,161 +1,99 @@
<template>
<div id="page-wrapper" class="page p-0 sm:p-6 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
<main class="md:container mx-auto md:h-full max-w-screen-lg p-0 md:p-6">
<article class="max-h-full md:overflow-y-auto relative flex flex-col rounded-md" @drop="drop" @dragover="dragover" @dragleave="dragleave" @dragenter="dragenter">
<h1 class="text-xl font-book px-8 pt-4 pb-2">Audiobook Uploader</h1>
<div class="w-full max-w-6xl mx-auto">
<!-- Library & folder picker -->
<div class="flex my-6 -mx-2">
<div class="w-1/3 px-2">
<ui-dropdown v-model="selectedLibraryId" :items="libraryItems" label="Library" :disabled="processing" @input="libraryChanged" />
</div>
<div class="w-2/3 px-2">
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="!selectedLibraryId || processing" label="Folder" />
</div>
</div>
<div class="flex my-2 px-6">
<div class="w-1/3 px-2">
<!-- <ui-text-input-with-label v-model="title" label="Title" /> -->
<ui-dropdown v-model="selectedLibraryId" :items="libraryItems" label="Library" @input="libraryChanged" />
</div>
<div class="w-2/3 px-2">
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="!selectedLibraryId" label="Folder" />
<widgets-alert v-if="error" type="error">
<p class="text-lg">{{ error }}</p>
</widgets-alert>
<!-- Picker display -->
<div v-if="!books.length && !ignoredFiles.length" class="w-full mx-auto border border-white border-opacity-20 px-12 pt-12 pb-4 my-12 relative" :class="isDragging ? 'bg-primary bg-opacity-40' : 'border-dashed'">
<p class="text-2xl text-center">{{ isDragging ? 'Drop files' : "Drag n' drop files or folders" }}</p>
<p class="text-center text-sm my-5">or</p>
<div class="w-full max-w-xl mx-auto">
<div class="flex">
<ui-btn class="w-full mx-1" @click="openFilePicker">Choose files</ui-btn>
<ui-btn class="w-full mx-1" @click="openFolderPicker">Choose a folder</ui-btn>
</div>
</div>
<div class="flex my-2 px-6">
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="title" label="Title" />
</div>
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="author" label="Author" />
</div>
<div class="pt-8 text-center">
<p class="text-xs text-white text-opacity-50 font-mono"><strong>Supported File Types: </strong>{{ inputAccept.join(', ') }}</p>
</div>
<div class="flex my-2 px-6">
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="series" label="Series" note="(optional)" />
</div>
<div class="w-1/2 px-2">
<div class="w-full">
<p class="px-1 text-sm font-semibold">Directory <em class="font-normal text-xs pl-2">(auto)</em></p>
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 42px" />
</div>
</div>
</div>
<!-- Book list header -->
<div v-else class="w-full flex items-center pb-4 border-b border-white border-opacity-10">
<p class="text-lg">{{ books.length }} book{{ books.length === 1 ? '' : 's' }}</p>
<p v-if="ignoredFiles.length" class="text-lg">&nbsp;|&nbsp;{{ ignoredFiles.length }} file{{ ignoredFiles.length === 1 ? '' : 's' }} ignored</p>
<div class="flex-grow" />
<ui-btn :disabled="processing" small @click="reset">Reset</ui-btn>
</div>
<!-- Alerts -->
<widgets-alert v-if="!books.length && !uploadReady" type="error" class="my-4">
<p class="text-lg">No books found</p>
</widgets-alert>
<widgets-alert v-if="ignoredFiles.length" type="warning" class="my-4">
<div class="w-full pr-12">
<p class="text-base mb-1">Unsupported files are ignored. When choosing or dropping a folder, other files that are not in a book folder are ignored.</p>
<tables-uploaded-files-table :files="ignoredFiles" title="Ignored Files" class="text-white" />
<p class="text-xs text-white text-opacity-50 font-mono pt-1"><strong>Supported File Types: </strong>{{ inputAccept.join(', ') }}</p>
</div>
</widgets-alert>
<section v-if="showUploader" class="h-full overflow-auto p-8 w-full flex flex-col">
<header class="border-dashed border-2 border-gray-400 py-12 flex flex-col justify-center items-center relative h-40" :class="isDragOver ? 'bg-white bg-opacity-10' : ''">
<p v-show="isDragOver" class="mb-3 font-semibold text-gray-200 flex flex-wrap justify-center">Drop em'</p>
<p v-show="!isDragOver" class="mb-3 font-semibold text-gray-200 flex flex-wrap justify-center">Drop your audio and image files or</p>
<!-- Book Upload cards -->
<template v-for="(book, index) in books">
<cards-book-upload-card :ref="`bookCard-${book.index}`" :key="index" :book="book" :processing="processing" @remove="removeBook(book)" />
</template>
<input ref="fileInput" id="hidden-input" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
<ui-btn @click="clickSelectAudioFiles">Select files</ui-btn>
<p class="text-xs text-gray-300 absolute bottom-3 right-3">{{ inputAccept }}</p>
</header>
</section>
<section v-else class="h-full overflow-auto px-8 pb-8 w-full flex flex-col">
<p v-if="!hasValidAudioFiles" class="text-error text-lg pt-4">* No valid audio tracks</p>
<!-- Upload/Reset btns -->
<div v-show="books.length" class="flex justify-end pb-8 pt-4">
<ui-btn v-if="!uploadFinished" color="success" :loading="processing" @click="submit">Upload</ui-btn>
<ui-btn v-else @click="reset">Reset</ui-btn>
</div>
</div>
<div v-if="validImageFiles.length">
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-200">Cover Image(s)</h1>
<div class="flex">
<template v-for="file in validImageFiles">
<div :key="file.name" class="h-28 w-20 bg-bg">
<img :src="file.src" class="h-full w-full object-contain" />
</div>
</template>
</div>
</div>
<div v-if="validAudioFiles.length">
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-200">Audio Tracks</h1>
<table class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left">Filename</th>
<th class="text-left">Type</th>
<th class="text-left">Size</th>
</tr>
<template v-for="file in validAudioFiles">
<tr :key="file.name">
<td class="font-book">
<p class="truncate">{{ file.name }}</p>
</td>
<td class="font-sm">
{{ file.type }}
</td>
<td class="font-mono">
{{ $bytesPretty(file.size) }}
</td>
</tr>
</template>
</table>
</div>
<div v-if="invalidFiles.length">
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-200">Invalid Files</h1>
<table class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left">Filename</th>
<th class="text-left">Type</th>
<th class="text-left">Size</th>
</tr>
<template v-for="file in invalidFiles">
<tr :key="file.name">
<td class="font-book">
<p class="truncate">{{ file.name }}</p>
</td>
<td class="font-sm">
{{ file.type }}
</td>
<td class="font-mono">
{{ $bytesPretty(file.size) }}
</td>
</tr>
</template>
</table>
</div>
</section>
<footer v-show="!showUploader" class="flex justify-end px-8 pb-8 pt-4">
<ui-btn :disabled="!hasValidAudioFiles" color="success" @click="submit">Upload Audiobook</ui-btn>
<button id="cancel" class="ml-3 rounded-sm px-3 py-1 hover:bg-white hover:bg-opacity-10 focus:shadow-outline focus:outline-none" @click="cancel">Cancel</button>
</footer>
<div v-if="processing" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20">
<ui-loading-indicator text="Uploading..." />
</div>
</article>
</main>
<input ref="fileInput" id="hidden-input" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
<input ref="fileFolderInput" id="hidden-input" type="file" webkitdirectory multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
</div>
</template>
<script>
import Path from 'path'
import uploadHelpers from '@/mixins/uploadHelpers'
export default {
mixins: [uploadHelpers],
data() {
return {
processing: false,
title: null,
author: null,
series: null,
acceptedAudioFormats: ['.mp3', '.m4b', '.m4a', '.flac', '.opus', '.mp4', '.aac'],
acceptedImageFormats: ['.png', '.jpg', '.jpeg', '.webp'],
inputAccept: '.png, .jpg, .jpeg, .webp, .mp3, .m4b, .m4a, .flac, .opus, .mp4, .aac',
isDragOver: false,
showUploader: true,
validAudioFiles: [],
validImageFiles: [],
invalidFiles: [],
isDragging: false,
error: '',
books: [],
ignoredFiles: [],
selectedLibraryId: null,
selectedFolderId: null
selectedFolderId: null,
processing: false,
uploadFinished: false
}
},
computed: {
inputAccept() {
var extensions = []
Object.values(this.$constants.SupportedFileTypes).forEach((types) => {
extensions = extensions.concat(types.map((t) => `.${t}`))
})
return extensions
},
streamAudiobook() {
return this.$store.state.streamAudiobook
},
hasValidAudioFiles() {
return this.validAudioFiles.length
},
directory() {
if (!this.author || !this.title) return ''
if (this.series) {
return Path.join(this.author, this.series, this.title)
} else {
return Path.join(this.author, this.title)
}
},
libraries() {
return this.$store.state.libraries.libraries
},
@ -182,6 +120,9 @@ export default {
text: fold.fullPath
}
})
},
uploadReady() {
return !this.books.length && !this.ignoredFiles.length && !this.uploadFinished
}
},
methods: {
@ -200,114 +141,171 @@ export default {
this.selectedFolderId = this.selectedLibrary.folders[0].id
}
},
reset() {
this.title = ''
this.author = ''
this.series = ''
this.cancel()
},
cancel() {
this.validAudioFiles = []
this.validImageFiles = []
this.invalidFiles = []
if (this.$refs.fileInput) {
this.$refs.fileInput.value = ''
removeBook(book) {
this.books = this.books.filter((b) => b.index !== book.index)
if (!this.books.length) {
this.reset()
}
this.showUploader = true
},
reset() {
this.error = ''
this.books = []
this.ignoredFiles = []
this.uploadFinished = false
if (this.$refs.fileInput) this.$refs.fileInput.value = ''
if (this.$refs.fileFolderInput) this.$refs.fileFolderInput.value = ''
},
openFilePicker() {
if (this.$refs.fileInput) this.$refs.fileInput.click()
},
openFolderPicker() {
if (this.$refs.fileFolderInput) this.$refs.fileFolderInput.click()
},
isDraggingFile(e) {
// Checks dragging file or folder and not an element on the page
var dt = e.dataTransfer || {}
return dt.types && dt.types.indexOf('Files') >= 0
},
dragenter(e) {
e.preventDefault()
if (this.uploadReady && this.isDraggingFile(e) && !this.isDragging) {
this.isDragging = true
}
},
dragleave(e) {
e.preventDefault()
if (!e.fromElement && this.isDragging) {
this.isDragging = false
}
},
dragover(e) {
// This is required to catch the drop event
e.preventDefault()
},
async drop(e) {
e.preventDefault()
this.isDragging = false
var items = e.dataTransfer.items || []
var bookResults = await this.uploadHelpers.getBooksFromDrop(items)
this.setResults(bookResults)
},
inputChanged(e) {
if (!e.target || !e.target.files) return
var _files = Array.from(e.target.files)
if (_files && _files.length) {
this.filesChanged(_files)
var bookResults = this.uploadHelpers.getBooksFromPicker(_files)
this.setResults(bookResults)
}
},
drop(evt) {
this.isDragOver = false
this.preventDefaults(evt)
const files = [...evt.dataTransfer.files]
this.filesChanged(files)
setResults(bookResults) {
if (bookResults.error) {
this.error = bookResults.error
this.books = []
this.ignoredFiles = []
} else {
this.error = ''
this.books = bookResults.books
this.ignoredFiles = bookResults.ignoredFiles
}
console.log('Upload results', bookResults)
},
dragover(evt) {
this.isDragOver = true
this.preventDefaults(evt)
},
dragleave(evt) {
this.isDragOver = false
this.preventDefaults(evt)
},
dragenter(evt) {
this.isDragOver = true
this.preventDefaults(evt)
},
preventDefaults(e) {
e.preventDefault()
e.stopPropagation()
},
filesChanged(files) {
this.showUploader = false
for (let i = 0; i < files.length; i++) {
var file = files[i]
var ext = Path.extname(file.name)
if (this.acceptedAudioFormats.includes(ext)) {
this.validAudioFiles.push(file)
} else if (file.type.startsWith('image/')) {
file.src = URL.createObjectURL(file)
this.validImageFiles.push(file)
} else {
this.invalidFiles.push(file)
}
updateBookCardStatus(index, status) {
var ref = this.$refs[`bookCard-${index}`]
if (ref && ref.length) ref = ref[0]
if (!ref) {
console.error('Book card ref not found', index, this.$refs)
} else {
ref.setUploadStatus(status)
}
},
clickSelectAudioFiles() {
if (this.$refs.fileInput) {
this.$refs.fileInput.click()
}
},
submit() {
if (!this.title || !this.author) {
this.$toast.error('Must enter a title and author')
return
}
if (!this.selectedLibraryId || !this.selectedFolderId) {
this.$toast.error('Must select a library and folder')
return
}
this.processing = true
uploadBook(book) {
var form = new FormData()
form.set('title', this.title)
form.set('author', this.author)
form.set('series', this.series)
form.set('title', book.title)
form.set('author', book.author)
form.set('series', book.series)
form.set('library', this.selectedLibraryId)
form.set('folder', this.selectedFolderId)
var index = 0
var files = this.validAudioFiles.concat(this.validImageFiles)
files.forEach((file) => {
book.files.forEach((file) => {
form.set(`${index++}`, file)
})
this.$axios
return this.$axios
.$post('/upload', form)
.then((data) => {
this.$toast.success('Audiobook Uploaded Successfully')
this.reset()
this.processing = false
})
.then(() => true)
.catch((error) => {
console.error('Failed', error)
var errorMessage = error.response && error.response.data ? error.response.data : 'Oops, something went wrong...'
this.$toast.error(errorMessage)
this.processing = false
return false
})
},
validateBooks() {
var bookData = []
for (var book of this.books) {
var bookref = this.$refs[`bookCard-${book.index}`]
if (bookref && bookref.length) bookref = bookref[0]
if (!bookref) {
console.error('Invalid book index no ref', book.index, this.$refs.bookCard)
return false
} else {
var data = bookref.getData()
if (!data) {
return false
}
bookData.push(data)
}
}
return bookData
},
async submit() {
if (!this.selectedFolderId || !this.selectedLibraryId) {
this.$toast.error('Must select library and folder')
document.getElementById('page-wrapper').scroll({ top: 0, left: 0, behavior: 'smooth' })
return
}
var books = this.validateBooks()
if (!books) {
this.$toast.error('Some invalid books')
return
}
this.processing = true
var booksUploaded = 0
var booksFailed = 0
for (let i = 0; i < books.length; i++) {
var book = books[i]
this.updateBookCardStatus(book.index, 'uploading')
var result = await this.uploadBook(book)
if (result) booksUploaded++
else booksFailed++
this.updateBookCardStatus(book.index, result ? 'success' : 'failed')
}
if (booksUploaded) {
this.$toast.success(`Successfully uploaded ${booksUploaded} book${booksUploaded > 1 ? 's' : ''}`)
}
if (booksFailed) {
this.$toast.success(`Failed to upload ${booksFailed} book${booksFailed > 1 ? 's' : ''}`)
}
this.processing = false
this.uploadFinished = true
}
},
mounted() {
this.selectedLibraryId = this.$store.state.libraries.currentLibraryId
this.setDefaultFolder()
window.addEventListener('dragenter', this.dragenter)
window.addEventListener('dragleave', this.dragleave)
window.addEventListener('dragover', this.dragover)
window.addEventListener('drop', this.drop)
},
beforeDestroy() {
window.removeEventListener('dragenter', this.dragenter)
window.removeEventListener('dragleave', this.dragleave)
window.removeEventListener('dragover', this.dragover)
window.removeEventListener('drop', this.drop)
}
}
</script>

View file

@ -0,0 +1,19 @@
export default class AudioTrack {
constructor(track) {
this.index = track.index || 0
this.startOffset = track.startOffset || 0 // Total time of all previous tracks
this.duration = track.duration || 0
this.title = track.filename || ''
this.contentUrl = track.contentUrl || null
this.mimeType = track.mimeType
}
get fullContentUrl() {
if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl
if (process.env.NODE_ENV === 'development') {
return `${process.env.serverUrl}${this.contentUrl}`
}
return `${window.location.origin}${this.contentUrl}`
}
}

View file

@ -0,0 +1,140 @@
import { buildCastLoadRequest, castLoadMedia } from "./castUtils"
import EventEmitter from 'events'
export default class CastPlayer extends EventEmitter {
constructor(ctx) {
super()
this.ctx = ctx
this.player = null
this.playerController = null
this.audiobook = null
this.audioTracks = []
this.currentTrackIndex = 0
this.hlsStreamId = null
this.currentTime = 0
this.playWhenReady = false
this.defaultPlaybackRate = 1
this.coverUrl = ''
this.castPlayerState = 'IDLE'
// Supported audio codecs for chromecast
this.supportedAudioCodecs = ['opus', 'mp3', 'aac', 'flac', 'webma', 'wav']
this.initialize()
}
get currentTrack() {
return this.audioTracks[this.currentTrackIndex] || {}
}
initialize() {
this.player = this.ctx.$root.castPlayer
this.playerController = this.ctx.$root.castPlayerController
this.playerController.addEventListener(
cast.framework.RemotePlayerEventType.MEDIA_INFO_CHANGED, this.evtMediaInfoChanged.bind(this))
}
evtMediaInfoChanged() {
// Use the current session to get an up to date media status.
let session = cast.framework.CastContext.getInstance().getCurrentSession()
if (!session) {
return
}
let media = session.getMediaSession()
if (!media) {
return
}
// var currentItemId = media.currentItemId
var currentItemId = media.media.itemId
if (currentItemId && this.currentTrackIndex !== currentItemId - 1) {
this.currentTrackIndex = currentItemId - 1
}
if (media.playerState !== this.castPlayerState) {
this.emit('stateChange', media.playerState)
this.castPlayerState = media.playerState
}
}
destroy() {
if (this.playerController) {
this.playerController.stop()
}
}
async set(audiobook, tracks, hlsStreamId, startTime, playWhenReady = false) {
this.audiobook = audiobook
this.audioTracks = tracks
this.hlsStreamId = hlsStreamId
this.playWhenReady = playWhenReady
this.currentTime = startTime
var coverImg = this.ctx.$store.getters['audiobooks/getBookCoverSrc'](audiobook)
if (process.env.NODE_ENV === 'development') {
this.coverUrl = coverImg
} else {
this.coverUrl = `${window.location.origin}${coverImg}`
}
var request = buildCastLoadRequest(this.audiobook, this.coverUrl, this.audioTracks, this.currentTime, playWhenReady, this.defaultPlaybackRate)
var castSession = cast.framework.CastContext.getInstance().getCurrentSession()
await castLoadMedia(castSession, request)
}
resetStream(startTime) {
// Cast only direct play for now
}
playPause() {
if (this.playerController) this.playerController.playOrPause()
}
play() {
if (this.playerController) this.playerController.playOrPause()
}
pause() {
if (this.playerController) this.playerController.playOrPause()
}
getCurrentTime() {
var currentTrackOffset = this.currentTrack.startOffset || 0
return this.player ? currentTrackOffset + this.player.currentTime : 0
}
getDuration() {
if (!this.audioTracks.length) return 0
var lastTrack = this.audioTracks[this.audioTracks.length - 1]
return lastTrack.startOffset + lastTrack.duration
}
setPlaybackRate(playbackRate) {
this.defaultPlaybackRate = playbackRate
}
async seek(time, playWhenReady) {
if (!this.player) return
if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) {
// Change Track
var request = buildCastLoadRequest(this.audiobook, this.coverUrl, this.audioTracks, time, playWhenReady, this.defaultPlaybackRate)
var castSession = cast.framework.CastContext.getInstance().getCurrentSession()
await castLoadMedia(castSession, request)
} else {
var offsetTime = time - (this.currentTrack.startOffset || 0)
this.player.currentTime = Math.max(0, offsetTime)
this.playerController.seek()
}
}
setVolume(volume) {
if (!this.player) return
this.player.volumeLevel = volume
this.playerController.setVolumeLevel()
}
}

View file

@ -0,0 +1,238 @@
import Hls from 'hls.js'
import EventEmitter from 'events'
export default class LocalPlayer extends EventEmitter {
constructor(ctx) {
super()
this.ctx = ctx
this.player = null
this.audiobook = null
this.audioTracks = []
this.currentTrackIndex = 0
this.hlsStreamId = null
this.hlsInstance = null
this.usingNativeplayer = false
this.currentTime = 0
this.playWhenReady = false
this.defaultPlaybackRate = 1
this.initialize()
}
get currentTrack() {
return this.audioTracks[this.currentTrackIndex] || {}
}
initialize() {
if (document.getElementById('audio-player')) {
document.getElementById('audio-player').remove()
}
var audioEl = document.createElement('audio')
audioEl.id = 'audio-player'
audioEl.style.display = 'none'
document.body.appendChild(audioEl)
this.player = audioEl
this.player.addEventListener('play', this.evtPlay.bind(this))
this.player.addEventListener('pause', this.evtPause.bind(this))
this.player.addEventListener('progress', this.evtProgress.bind(this))
this.player.addEventListener('error', this.evtError.bind(this))
this.player.addEventListener('loadedmetadata', this.evtLoadedMetadata.bind(this))
this.player.addEventListener('timeupdate', this.evtTimeupdate.bind(this))
}
evtPlay() {
this.emit('stateChange', 'PLAYING')
}
evtPause() {
this.emit('stateChange', 'PAUSED')
}
evtProgress() {
var lastBufferTime = this.getLastBufferedTime()
this.emit('buffertimeUpdate', lastBufferTime)
}
evtError(error) {
console.error('Player error', error)
}
evtLoadedMetadata(data) {
console.log('Audio Loaded Metadata', data)
this.emit('stateChange', 'LOADED')
if (this.playWhenReady) {
this.playWhenReady = false
this.play()
}
}
evtTimeupdate() {
if (this.player.paused) {
this.emit('timeupdate', this.getCurrentTime())
}
}
destroy() {
if (this.hlsStreamId) {
// Close HLS Stream
console.log('Closing HLS Streams', this.hlsStreamId)
this.ctx.$axios.$post(`/api/streams/${this.hlsStreamId}/close`).catch((error) => {
console.error('Failed to request close hls stream', this.hlsStreamId, error)
})
}
this.destroyHlsInstance()
if (this.player) {
this.player.remove()
}
}
set(audiobook, tracks, hlsStreamId, startTime, playWhenReady = false) {
this.audiobook = audiobook
this.audioTracks = tracks
this.hlsStreamId = hlsStreamId
this.playWhenReady = playWhenReady
if (this.hlsInstance) {
this.destroyHlsInstance()
}
this.currentTime = startTime
// iOS does not support Media Elements but allows for HLS in the native audio player
if (!Hls.isSupported()) {
console.warn('HLS is not supported - fallback to using audio element')
this.usingNativeplayer = true
this.player.src = this.currentTrack.contentUrl
this.player.currentTime = this.currentTime
return
}
var hlsOptions = {
startPosition: this.currentTime || -1
// No longer needed because token is put in a query string
// xhrSetup: (xhr) => {
// xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
// }
}
this.hlsInstance = new Hls(hlsOptions)
this.hlsInstance.attachMedia(this.player)
this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
this.hlsInstance.loadSource(this.currentTrack.contentUrl)
this.hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
console.log('[HLS] Manifest Parsed')
})
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
console.error('[HLS] Error', data.type, data.details, data)
if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
console.error('[HLS] BUFFER STALLED ERROR')
}
})
this.hlsInstance.on(Hls.Events.DESTROYING, () => {
console.log('[HLS] Destroying HLS Instance')
})
})
}
destroyHlsInstance() {
if (!this.hlsInstance) return
if (this.hlsInstance.destroy) {
var temp = this.hlsInstance
temp.destroy()
}
this.hlsInstance = null
}
async resetStream(startTime) {
this.destroyHlsInstance()
await new Promise((resolve) => setTimeout(resolve, 1000))
this.set(this.audiobook, this.audioTracks, this.hlsStreamId, startTime, true)
}
playPause() {
if (!this.player) return
if (this.player.paused) this.play()
else this.pause()
}
play() {
if (this.player) this.player.play()
}
pause() {
if (this.player) this.player.pause()
}
getCurrentTime() {
var currentTrackOffset = this.currentTrack.startOffset || 0
return this.player ? currentTrackOffset + this.player.currentTime : 0
}
getDuration() {
if (!this.audioTracks.length) return 0
var lastTrack = this.audioTracks[this.audioTracks.length - 1]
return lastTrack.startOffset + lastTrack.duration
}
setPlaybackRate(playbackRate) {
if (!this.player) return
this.defaultPlaybackRate = playbackRate
this.player.playbackRate = playbackRate
}
seek(time) {
if (!this.player) return
var offsetTime = time - (this.currentTrack.startOffset || 0)
this.player.currentTime = Math.max(0, offsetTime)
}
setVolume(volume) {
if (!this.player) return
this.player.volume = volume
}
// Utils
isValidDuration(duration) {
if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) {
return true
}
return false
}
getBufferedRanges() {
if (!this.player) return []
const ranges = []
const seekable = this.player.buffered || []
let offset = 0
for (let i = 0, length = seekable.length; i < length; i++) {
let start = seekable.start(i)
let end = seekable.end(i)
if (!this.isValidDuration(start)) {
start = 0
}
if (!this.isValidDuration(end)) {
end = 0
continue
}
ranges.push({
start: start + offset,
end: end + offset
})
}
return ranges
}
getLastBufferedTime() {
var bufferedRanges = this.getBufferedRanges()
if (!bufferedRanges.length) return 0
var buff = bufferedRanges.find((buff) => buff.start < this.player.currentTime && buff.end > this.player.currentTime)
if (buff) return buff.end
var last = bufferedRanges[bufferedRanges.length - 1]
return last.end
}
}

View file

@ -0,0 +1,306 @@
import LocalPlayer from './LocalPlayer'
import CastPlayer from './CastPlayer'
import AudioTrack from './AudioTrack'
export default class PlayerHandler {
constructor(ctx) {
this.ctx = ctx
this.audiobook = null
this.playWhenReady = false
this.player = null
this.playerState = 'IDLE'
this.currentStreamId = null
this.startTime = 0
this.lastSyncTime = 0
this.lastSyncedAt = 0
this.listeningTimeSinceSync = 0
this.playInterval = null
}
get isCasting() {
return this.ctx.$store.state.globals.isCasting
}
get isPlayingCastedAudiobook() {
return this.audiobook && (this.player instanceof CastPlayer)
}
get isPlayingLocalAudiobook() {
return this.audiobook && (this.player instanceof LocalPlayer)
}
get userToken() {
return this.ctx.$store.getters['user/getToken']
}
get playerPlaying() {
return this.playerState === 'PLAYING'
}
load(audiobook, playWhenReady, startTime = 0) {
if (!this.player) this.switchPlayer()
console.log('Load audiobook', audiobook)
this.audiobook = audiobook
this.startTime = startTime
this.playWhenReady = playWhenReady
this.prepare()
}
switchPlayer() {
if (this.isCasting && !(this.player instanceof CastPlayer)) {
console.log('[PlayerHandler] Switching to cast player')
this.stopPlayInterval()
this.playerStateChange('LOADING')
this.startTime = this.player ? this.player.getCurrentTime() : this.startTime
if (this.player) {
this.player.destroy()
}
this.player = new CastPlayer(this.ctx)
this.setPlayerListeners()
if (this.audiobook) {
// Audiobook was already loaded - prepare for cast
this.playWhenReady = false
this.prepare()
}
} else if (!this.isCasting && !(this.player instanceof LocalPlayer)) {
console.log('[PlayerHandler] Switching to local player')
this.stopPlayInterval()
this.playerStateChange('LOADING')
if (this.player) {
this.player.destroy()
}
this.player = new LocalPlayer(this.ctx)
this.setPlayerListeners()
if (this.audiobook) {
// Audiobook was already loaded - prepare for local play
this.playWhenReady = false
this.prepare()
}
}
}
setPlayerListeners() {
this.player.on('stateChange', this.playerStateChange.bind(this))
this.player.on('timeupdate', this.playerTimeupdate.bind(this))
this.player.on('buffertimeUpdate', this.playerBufferTimeUpdate.bind(this))
}
playerStateChange(state) {
console.log('[PlayerHandler] Player state change', state)
this.playerState = state
if (this.playerState === 'PLAYING') {
this.startPlayInterval()
} else {
this.stopPlayInterval()
}
if (this.playerState === 'LOADED' || this.playerState === 'PLAYING') {
this.ctx.setDuration(this.player.getDuration())
}
if (this.playerState !== 'LOADING') {
this.ctx.setCurrentTime(this.player.getCurrentTime())
}
this.ctx.isPlaying = this.playerState === 'PLAYING'
this.ctx.playerLoading = this.playerState === 'LOADING'
}
playerTimeupdate(time) {
this.ctx.setCurrentTime(time)
}
playerBufferTimeUpdate(buffertime) {
this.ctx.setBufferTime(buffertime)
}
async prepare() {
var useHls = !this.isCasting
if (useHls) {
var stream = await this.ctx.$axios.$get(`/api/books/${this.audiobook.id}/stream`).catch((error) => {
console.error('Failed to start stream', error)
})
if (stream) {
console.log(`[PlayerHandler] prepare hls stream`, stream)
this.setHlsStream(stream)
}
} else {
// Setup tracks
var runningTotal = 0
var audioTracks = (this.audiobook.tracks || []).map((track) => {
var audioTrack = new AudioTrack(track)
audioTrack.startOffset = runningTotal
audioTrack.contentUrl = `/lib/${this.audiobook.libraryId}/${this.audiobook.folderId}/${track.path}?token=${this.userToken}`
audioTrack.mimeType = (track.codec === 'm4b' || track.codec === 'm4a') ? 'audio/mp4' : `audio/${track.codec}`
runningTotal += audioTrack.duration
return audioTrack
})
this.setDirectPlay(audioTracks)
}
}
closePlayer() {
console.log('[PlayerHandler] CLose Player')
if (this.player) {
this.player.destroy()
}
this.player = null
this.playerState = 'IDLE'
this.audiobook = null
this.currentStreamId = null
this.startTime = 0
this.stopPlayInterval()
}
prepareStream(stream) {
if (!this.player) this.switchPlayer()
this.audiobook = stream.audiobook
this.setHlsStream({
streamId: stream.id,
streamUrl: stream.clientPlaylistUri,
startTime: stream.clientCurrentTime
})
}
setHlsStream(stream) {
this.currentStreamId = stream.streamId
var audioTrack = new AudioTrack({
duration: this.audiobook.duration,
contentUrl: stream.streamUrl + '?token=' + this.userToken,
mimeType: 'application/vnd.apple.mpegurl'
})
this.startTime = stream.startTime
this.ctx.playerLoading = true
this.player.set(this.audiobook, [audioTrack], this.currentStreamId, stream.startTime, this.playWhenReady)
}
setDirectPlay(audioTracks) {
this.currentStreamId = null
this.ctx.playerLoading = true
this.player.set(this.audiobook, audioTracks, null, this.startTime, this.playWhenReady)
}
resetStream(startTime, streamId) {
if (this.currentStreamId === streamId) {
this.player.resetStream(startTime)
} else {
console.warn('resetStream mismatch streamId', this.currentStreamId, streamId)
}
}
startPlayInterval() {
clearInterval(this.playInterval)
var lastTick = Date.now()
this.playInterval = setInterval(() => {
// Update UI
if (!this.player) return
var currentTime = this.player.getCurrentTime()
this.ctx.setCurrentTime(currentTime)
var exactTimeElapsed = ((Date.now() - lastTick) / 1000)
lastTick = Date.now()
this.listeningTimeSinceSync += exactTimeElapsed
if (this.listeningTimeSinceSync >= 5) {
this.sendProgressSync(currentTime)
this.listeningTimeSinceSync = 0
}
}, 1000)
}
sendProgressSync(currentTime) {
var diffSinceLastSync = Math.abs(this.lastSyncTime - currentTime)
if (diffSinceLastSync < 1) return
this.lastSyncTime = currentTime
if (this.currentStreamId) { // Updating stream progress (HLS stream)
var listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync))
var syncData = {
timeListened: listeningTimeToAdd,
currentTime,
streamId: this.currentStreamId,
audiobookId: this.audiobook.id
}
this.ctx.$axios.$post('/api/syncStream', syncData, { timeout: 1000 }).catch((error) => {
console.error('Failed to update stream progress', error)
})
} else {
// Direct play via chromecast does not yet have backend stream session model
// so the progress update for the audiobook is updated this way (instead of through the stream)
var duration = this.getDuration()
var syncData = {
totalDuration: duration,
currentTime,
progress: duration > 0 ? currentTime / duration : 0,
isRead: false,
audiobookId: this.audiobook.id,
lastUpdate: Date.now()
}
this.ctx.$axios.$post('/api/syncLocal', syncData, { timeout: 1000 }).catch((error) => {
console.error('Failed to update local progress', error)
})
}
}
stopPlayInterval() {
clearInterval(this.playInterval)
this.playInterval = null
}
playPause() {
if (this.player) this.player.playPause()
}
play() {
if (!this.player) return
this.player.play()
}
pause() {
if (this.player) this.player.pause()
}
getCurrentTime() {
return this.player ? this.player.getCurrentTime() : 0
}
getDuration() {
return this.player ? this.player.getDuration() : 0
}
jumpBackward() {
if (!this.player) return
var currentTime = this.getCurrentTime()
this.seek(Math.max(0, currentTime - 10))
}
jumpForward() {
if (!this.player) return
var currentTime = this.getCurrentTime()
this.seek(Math.min(currentTime + 10, this.getDuration()))
}
setVolume(volume) {
if (!this.player) return
this.player.setVolume(volume)
}
setPlaybackRate(playbackRate) {
if (!this.player) return
this.player.setPlaybackRate(playbackRate)
}
seek(time) {
if (!this.player) return
this.player.seek(time, this.playerPlaying)
this.ctx.setCurrentTime(time)
// Update progress if paused
if (!this.playerPlaying) {
this.sendProgressSync(time)
}
}
}

View file

@ -0,0 +1,74 @@
function getMediaInfoFromTrack(audiobook, castImage, track) {
// https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.media.AudiobookChapterMediaMetadata
var metadata = new chrome.cast.media.AudiobookChapterMediaMetadata()
metadata.bookTitle = audiobook.book.title
metadata.chapterNumber = track.index
metadata.chapterTitle = track.title
metadata.images = [castImage]
metadata.title = track.title
metadata.subtitle = audiobook.book.title
var trackurl = track.fullContentUrl
var mimeType = track.mimeType
var mediainfo = new chrome.cast.media.MediaInfo(trackurl, mimeType)
mediainfo.metadata = metadata
mediainfo.itemId = track.index
mediainfo.duration = track.duration
return mediainfo
}
function buildCastMediaInfo(audiobook, coverUrl, tracks) {
const castImage = new chrome.cast.Image(coverUrl)
return tracks.map(t => getMediaInfoFromTrack(audiobook, castImage, t))
}
function buildCastQueueRequest(audiobook, coverUrl, tracks, startTime) {
var mediaInfoItems = buildCastMediaInfo(audiobook, coverUrl, tracks)
var containerMetadata = new chrome.cast.media.AudiobookContainerMetadata()
containerMetadata.authors = [audiobook.book.authorFL]
containerMetadata.narrators = [audiobook.book.narratorFL]
containerMetadata.publisher = audiobook.book.publisher || undefined
var mediaQueueItems = mediaInfoItems.map((mi) => {
var queueItem = new chrome.cast.media.QueueItem(mi)
return queueItem
})
// Find track to start playback and calculate track start offset
var track = tracks.find(at => at.startOffset <= startTime && at.startOffset + at.duration > startTime)
var trackStartIndex = track ? track.index - 1 : 0
var trackStartTime = Math.floor(track ? startTime - track.startOffset : 0)
var queueData = new chrome.cast.media.QueueData(audiobook.id, audiobook.book.title, '', false, mediaQueueItems, trackStartIndex, trackStartTime)
queueData.containerMetadata = containerMetadata
queueData.queueType = chrome.cast.media.QueueType.AUDIOBOOK
return queueData
}
function castLoadMedia(castSession, request) {
return new Promise((resolve) => {
castSession.loadMedia(request)
.then(() => resolve(true), (reason) => {
console.error('Load media failed', reason)
resolve(false)
})
})
}
function buildCastLoadRequest(audiobook, coverUrl, tracks, startTime, autoplay, playbackRate) {
var request = new chrome.cast.media.LoadRequest()
request.queueData = buildCastQueueRequest(audiobook, coverUrl, tracks, startTime)
request.currentTime = request.queueData.startTime
request.autoplay = autoplay
request.playbackRate = playbackRate
return request
}
export {
buildCastLoadRequest,
castLoadMedia
}

View file

@ -0,0 +1,80 @@
export default (ctx) => {
var sendInit = async (castContext) => {
// Fetch background covers for chromecast (temp)
var covers = await ctx.$axios.$get(`/api/libraries/${ctx.$store.state.libraries.currentLibraryId}/books/all?limit=40&minified=1`).then((data) => {
return data.results.filter((b) => b.book.cover).map((ab) => {
var coverUrl = ctx.$store.getters['audiobooks/getBookCoverSrc'](ab)
if (process.env.NODE_ENV === 'development') return coverUrl
return `${window.location.origin}${coverUrl}`
})
}).catch((error) => {
console.error('failed to fetch books', error)
return null
})
// Custom message to receiver
var castSession = castContext.getCurrentSession()
castSession.sendMessage('urn:x-cast:com.audiobookshelf.cast', {
covers
})
}
var initializeCastApi = () => {
var castContext = cast.framework.CastContext.getInstance()
castContext.setOptions({
receiverApplicationId: process.env.chromecastReceiver,
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED
});
castContext.addEventListener(
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
(event) => {
console.log('Session state changed event', event)
switch (event.sessionState) {
case cast.framework.SessionState.SESSION_STARTED:
console.log('[chromecast] CAST SESSION STARTED')
ctx.$store.commit('globals/setCasting', true)
sendInit(castContext)
setTimeout(() => {
ctx.$eventBus.$emit('cast-session-active', true)
}, 500)
break;
case cast.framework.SessionState.SESSION_RESUMED:
console.log('[chromecast] CAST SESSION RESUMED')
setTimeout(() => {
ctx.$eventBus.$emit('cast-session-active', true)
}, 500)
break;
case cast.framework.SessionState.SESSION_ENDED:
console.log('[chromecast] CAST SESSION DISCONNECTED')
ctx.$store.commit('globals/setCasting', false)
ctx.$eventBus.$emit('cast-session-active', false)
break;
}
})
ctx.$store.commit('globals/setChromecastInitialized', true)
var player = new cast.framework.RemotePlayer()
var playerController = new cast.framework.RemotePlayerController(player)
ctx.$root.castPlayer = player
ctx.$root.castPlayerController = playerController
}
window['__onGCastApiAvailable'] = function (isAvailable) {
if (isAvailable) {
initializeCastApi()
}
}
var script = document.createElement('script')
script.type = 'text/javascript'
script.src = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1'
document.head.appendChild(script)
}

View file

@ -1,3 +1,12 @@
const SupportedFileTypes = {
image: ['png', 'jpg', 'jpeg', 'webp'],
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'mp4', 'aac'],
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
info: ['nfo'],
text: ['txt'],
opf: ['opf']
}
const DownloadStatus = {
PENDING: 0,
READY: 1,
@ -5,11 +14,6 @@ const DownloadStatus = {
FAILED: 3
}
const CoverDestination = {
METADATA: 0,
AUDIOBOOK: 1
}
const BookCoverAspectRatio = {
STANDARD: 0,
SQUARE: 1
@ -21,8 +25,8 @@ const BookshelfView = {
}
const Constants = {
SupportedFileTypes,
DownloadStatus,
CoverDestination,
BookCoverAspectRatio,
BookshelfView
}

View file

@ -25,7 +25,7 @@ Vue.prototype.$addDaysToToday = (daysToAdd) => {
}
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
if (bytes === 0) {
if (isNaN(bytes) || !bytes === 0) {
return '0 Bytes'
}
const k = 1024
@ -61,13 +61,20 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
}
Vue.prototype.$elapsedPrettyExtended = (seconds) => {
Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true) => {
if (isNaN(seconds) || seconds === null) return ''
seconds = Math.round(seconds)
var minutes = Math.floor(seconds / 60)
seconds -= minutes * 60
var hours = Math.floor(minutes / 60)
minutes -= hours * 60
var days = Math.floor(hours / 24)
hours -= days * 24
var days = 0
if (useDays || Math.floor(hours / 24) >= 100) {
days = Math.floor(hours / 24)
hours -= days * 24
}
var strs = []
if (days) strs.push(`${days}d`)

File diff suppressed because one or more lines are too long

View file

@ -6,7 +6,9 @@ export const state = () => ({
showUserCollectionsModal: false,
showEditCollectionModal: false,
selectedCollection: null,
showBookshelfTextureModal: false
showBookshelfTextureModal: false,
isCasting: false, // Actively casting
isChromecastInitialized: false // Script loaded
})
export const getters = {}
@ -33,5 +35,11 @@ export const mutations = {
},
setShowBookshelfTextureModal(state, val) {
state.showBookshelfTextureModal = val
},
setChromecastInitialized(state, val) {
state.isChromecastInitialized = val
},
setCasting(state, val) {
state.isCasting = val
}
}

View file

@ -9,7 +9,6 @@ export const state = () => ({
showEReader: false,
selectedAudiobook: null,
selectedAudiobookFile: null,
playOnLoad: false,
developerMode: false,
selectedAudiobooks: [],
processingBatch: false,
@ -78,25 +77,8 @@ export const mutations = {
state.versionData = versionData
},
setStreamAudiobook(state, audiobook) {
state.playOnLoad = true
state.streamAudiobook = audiobook
},
updateStreamAudiobook(state, audiobook) { // Initial stream audiobook is minified, on open audiobook is updated to full
state.streamAudiobook = audiobook
},
setStream(state, stream) {
state.playOnLoad = false
state.streamAudiobook = stream ? stream.audiobook : null
},
clearStreamAudiobook(state, audiobookId) {
if (state.streamAudiobook && state.streamAudiobook.id === audiobookId) {
state.playOnLoad = false
state.streamAudiobook = null
}
},
setPlayOnLoad(state, val) {
state.playOnLoad = val
},
showEditModal(state, audiobook) {
state.editModalTab = 'details'
state.selectedAudiobook = audiobook

View file

@ -20,6 +20,11 @@ export const getters = {
},
getSortedLibraries: state => () => {
return state.libraries.map(lib => ({ ...lib })).sort((a, b) => a.displayOrder - b.displayOrder)
},
getLibraryProvider: state => libraryId => {
var library = state.libraries.find(l => l.id === libraryId)
if (!library) return null
return library.provider
}
}