Skip to content

Commit 65e803c

Browse files
committed
Add still watching feature
1 parent c9ccec8 commit 65e803c

File tree

19 files changed

+579
-19
lines changed

19 files changed

+579
-19
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Actions that are triggered for still watching.
3+
*/
4+
export enum StillWatchingAction {
5+
Short = 'Short',
6+
Default = 'Default',
7+
Long = 'Long',
8+
VeryLong = 'VeryLong',
9+
Disabled = 'Disabled'
10+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { MILLISECONDS_PER_MINUTE, TICKS_PER_MILLISECOND, TICKS_PER_MINUTE } from '../../constants/time';
2+
import { StillWatchingAction } from 'apps/stable/features/playback/constants/stillWatchingAction';
3+
import { stillWatchingBehavior } from '../../scripts/settings/userSettings';
4+
5+
function itemIsEpisode (item) {
6+
return item.Type === 'Episode';
7+
}
8+
9+
function getStillWatchingConfig (setting) {
10+
switch (setting) {
11+
case StillWatchingAction.Default: return { episodeCount: 3, minMinutes: 90 };
12+
case StillWatchingAction.Short: return { episodeCount: 2, minMinutes: 60 };
13+
case StillWatchingAction.Long: return { episodeCount: 5, minMinutes: 150 };
14+
case StillWatchingAction.VeryLong: return { episodeCount: 8, minMinutes: 240 };
15+
default: return null;
16+
}
17+
}
18+
19+
class IdleManager {
20+
constructor(playbackManager) {
21+
this.playbackManager = playbackManager;
22+
this.episodeCount = 0;
23+
this.watchTicks = 0;
24+
this.lastUpdateTicks = 0;
25+
this.isWatchingEpisodes = false;
26+
this.episodeWasInterrupted = false;
27+
this.showStillWatching = false;
28+
this.stillWatchingShowing = false;
29+
this.stillWatchingSetting = null;
30+
}
31+
32+
notifyStartSession (item, items) {
33+
// No need to track when only watching 1 episode
34+
if (itemIsEpisode(item) && items.length > 1) {
35+
this.resetSession();
36+
this.episodeWasInterrupted = false;
37+
this.stillWatchingSetting = getStillWatchingConfig(stillWatchingBehavior());
38+
this.stillWatchingShowing = false;
39+
}
40+
}
41+
42+
notifyStart (item) {
43+
if (itemIsEpisode(item)) {
44+
this.isWatchingEpisodes = true;
45+
}
46+
}
47+
48+
onEpisodeWatched () {
49+
if (!this.episodeWasInterrupted) {
50+
this.episodeCount++;
51+
}
52+
this.calculateWatchTime();
53+
this.episodeWasInterrupted = false;
54+
}
55+
56+
notifyInteraction (item) {
57+
if (itemIsEpisode(item)) {
58+
this.resetSession();
59+
this.lastUpdateTicks = this.playbackManager.currentTime() * TICKS_PER_MILLISECOND;
60+
this.episodeWasInterrupted = true;
61+
}
62+
}
63+
64+
notifyPlay (item) {
65+
if (itemIsEpisode(item)) {
66+
this.calculateWatchTime();
67+
this.stillWatchingShowing = false;
68+
}
69+
}
70+
71+
async #checkStillWatchingStatus () {
72+
// User has disabled the Still Watching feature
73+
if (!this.stillWatchingSetting) { return; }
74+
75+
const minMinutesInMs = this.stillWatchingSetting.minMinutes * MILLISECONDS_PER_MINUTE;
76+
77+
const episodeRequirementMet = this.episodeCount >= this.stillWatchingSetting.episodeCount - 1;
78+
const watchTimeRequirementMet = (this.watchTicks / TICKS_PER_MINUTE) >= minMinutesInMs;
79+
80+
if (episodeRequirementMet || watchTimeRequirementMet) {
81+
this.showStillWatching = true;
82+
}
83+
}
84+
85+
calculateWatchTime () {
86+
const currentTimeTicks = this.playbackManager.currentTime() * TICKS_PER_MILLISECOND;
87+
this.watchTicks += currentTimeTicks - this.lastUpdateTicks;
88+
this.lastUpdateTicks = currentTimeTicks;
89+
this.#checkStillWatchingStatus();
90+
}
91+
92+
resetSession () {
93+
this.watchTicks = 0;
94+
this.episodeCount = 0;
95+
this.lastUpdateTicks = 0;
96+
this.showStillWatching = false;
97+
}
98+
}
99+
100+
export default IdleManager;

src/components/playback/playbackmanager.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import appSettings from '../../scripts/settings/appSettings';
1010
import itemHelper from '../itemHelper';
1111
import { pluginManager } from '../pluginManager';
1212
import PlayQueueManager from './playqueuemanager';
13+
import IdleManager from './idlemanager';
1314
import * as userSettings from '../../scripts/settings/userSettings';
1415
import globalize from '../../lib/globalize';
1516
import loading from '../loading/loading';
@@ -715,6 +716,7 @@ export class PlaybackManager {
715716
const playerStates = {};
716717

717718
this._playQueueManager = new PlayQueueManager();
719+
this._idleManager = new IdleManager(this);
718720

719721
self.currentItem = function (player) {
720722
if (!player) {
@@ -3267,6 +3269,8 @@ export class PlaybackManager {
32673269
Events.trigger(player, 'playbackstart', [state]);
32683270
Events.trigger(self, 'playbackstart', [player, state]);
32693271

3272+
if (self.getCurrentPlaylistIndex(self._currentPlayer) === 0) self._idleManager.notifyStartSession(streamInfo.item, self._playQueueManager.getPlaylist());
3273+
32703274
// only used internally as a safeguard to avoid reporting other events to the server before playback start
32713275
streamInfo.started = true;
32723276

@@ -3464,14 +3468,17 @@ export class PlaybackManager {
34643468

34653469
if (errorOccurred) {
34663470
showPlaybackInfoErrorMessage(self, 'PlaybackError' + displayErrorCode);
3467-
} else if (nextItem) {
3471+
} else if (nextItem && !self._idleManager.stillWatchingShowing) {
34683472
const apiClient = ServerConnections.getApiClient(nextItem.item.ServerId);
34693473

3470-
apiClient.getCurrentUser().then(function (user) {
3474+
apiClient.getUser(apiClient.getCurrentUserId()).then(function (user) {
34713475
if (user.Configuration.EnableNextEpisodeAutoPlay || nextMediaType !== MediaType.Video) {
3476+
self._idleManager.onEpisodeWatched();
34723477
self.nextTrack();
34733478
}
34743479
});
3480+
} else if (self._idleManager.stillWatchingShowing) {
3481+
self._idleManager.stillWatchingShowing = false;
34753482
}
34763483
}
34773484

src/components/playbackSettings/playbackSettings.js

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import escapeHTML from 'escape-html';
44
import { MediaSegmentAction } from 'apps/stable/features/playback/constants/mediaSegmentAction';
55
import { getId, getMediaSegmentAction } from 'apps/stable/features/playback/utils/mediaSegmentSettings';
66

7+
import { StillWatchingAction } from 'apps/stable/features/playback/constants/stillWatchingAction';
8+
79
import appSettings from '../../scripts/settings/appSettings';
810
import { appHost } from '../apphost';
911
import browser from '../../scripts/browser';
@@ -82,6 +84,19 @@ function populateMediaSegments(container, userSettings) {
8284
});
8385
}
8486

87+
function populateStillWatching (container) {
88+
const actionOptions = Object.values(StillWatchingAction)
89+
.map(action => {
90+
const actionLabel = globalize.translate(`StillWatchingAction.${action}`);
91+
return `<option value='${action}'>${actionLabel}</option>`;
92+
})
93+
.join('');
94+
95+
container.innerHTML = `<div class="selectContainer">
96+
<select is="emby-select" class="selectStillWatchingBehavior" label="${globalize.translate('StillWatchingLabel')}">${actionOptions}</select>
97+
</div>`;
98+
}
99+
85100
function fillQuality(select, isInNetwork, mediatype, maxVideoWidth) {
86101
const options = mediatype === 'Audio' ? qualityoptions.getAudioQualityOptions({
87102

@@ -196,7 +211,33 @@ function loadForm(context, user, userSettings, systemInfo, apiClient) {
196211
populateLanguages(context.querySelector('#selectAudioLanguage'), allCultures);
197212

198213
context.querySelector('#selectAudioLanguage', context).value = user.Configuration.AudioLanguagePreference || '';
199-
context.querySelector('.chkEpisodeAutoPlay').checked = user.Configuration.EnableNextEpisodeAutoPlay || false;
214+
const enableNextEpisodeAutoPlay = context.querySelector('.chkEpisodeAutoPlay');
215+
enableNextEpisodeAutoPlay.checked = user.Configuration.EnableNextEpisodeAutoPlay || false;
216+
217+
const stillWatchingContainer = context.querySelector('.stillWatchingSelectContainer');
218+
populateStillWatching(stillWatchingContainer);
219+
const stillWatchingSelect = context.querySelector('.selectStillWatchingBehavior');
220+
stillWatchingSelect.value = userSettings.stillWatchingBehavior();
221+
222+
if (user.Configuration.EnableNextEpisodeAutoPlay) {
223+
requestAnimationFrame(() => {
224+
stillWatchingContainer.style.maxHeight = stillWatchingContainer.scrollHeight + 'px';
225+
stillWatchingContainer.style.opacity = 1;
226+
});
227+
}
228+
229+
enableNextEpisodeAutoPlay.addEventListener('change', function () {
230+
if (stillWatchingContainer.style.maxHeight) {
231+
stillWatchingContainer.style.maxHeight = null;
232+
stillWatchingContainer.style.opacity = 0;
233+
stillWatchingSelect.value = StillWatchingAction.Disabled;
234+
235+
} else {
236+
stillWatchingSelect.value = userSettings.stillWatchingBehavior();
237+
stillWatchingContainer.style.maxHeight = stillWatchingContainer.scrollHeight + "px";
238+
stillWatchingContainer.style.opacity = 1;
239+
}
240+
});
200241
});
201242

202243
if (appHost.supports('externalplayerintent') && userId === loggedInUserId) {
@@ -292,6 +333,7 @@ function saveUser(context, user, userSettingsInstance, apiClient) {
292333
user.Configuration.AudioLanguagePreference = context.querySelector('#selectAudioLanguage').value;
293334
user.Configuration.PlayDefaultAudioTrack = context.querySelector('.chkPlayDefaultAudioTrack').checked;
294335
user.Configuration.EnableNextEpisodeAutoPlay = context.querySelector('.chkEpisodeAutoPlay').checked;
336+
userSettingsInstance.stillWatchingBehavior(context.querySelector('.selectStillWatchingBehavior').value);
295337
userSettingsInstance.preferFmp4HlsContainer(context.querySelector('.chkPreferFmp4HlsContainer').checked);
296338
userSettingsInstance.enableCinemaMode(context.querySelector('.chkEnableCinemaMode').checked);
297339
userSettingsInstance.selectAudioNormalization(context.querySelector('#selectAudioNormalization').value);

src/components/playbackSettings/playbackSettings.template.html

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ <h2 class="sectionTitle">
111111
</label>
112112
</div>
113113

114+
<div class="stillWatchingSelectContainer"></div>
115+
114116
<div class="checkboxContainer checkboxContainer-withDescription">
115117
<label>
116118
<input type="checkbox" is="emby-checkbox" class="chkRememberAudioSelections" />
@@ -249,3 +251,12 @@ <h2 class="sectionTitle">
249251
<span>${Save}</span>
250252
</button>
251253
</form>
254+
255+
<style>
256+
.stillWatchingSelectContainer {
257+
transition: max-height 1s ease, opacity 0.9s ease;
258+
overflow: hidden;
259+
max-height: 0;
260+
opacity: 0;
261+
}
262+
</style>

0 commit comments

Comments
 (0)