1
0
Fork 0
mirror of https://github.com/jellyfin/jellyfin-web synced 2025-03-30 19:56:21 +00:00

Merge pull request #4891 from Sky-High/playback-fixes-for-chromecast

Fix playback control issues with chromecast
This commit is contained in:
Bill Thornton 2023-11-08 16:54:35 -05:00 committed by GitHub
commit 34212614bc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 116 additions and 68 deletions

View file

@ -170,17 +170,20 @@ function bindEvents(elem) {
elem.querySelector('.previousTrackButton').addEventListener('click', function (e) { elem.querySelector('.previousTrackButton').addEventListener('click', function (e) {
if (currentPlayer) { if (currentPlayer) {
if (lastPlayerState.NowPlayingItem.MediaType === 'Audio') { if (playbackManager.isPlayingAudio(currentPlayer)) {
// Cancel this event if doubleclick is fired. The actual previousTrack will be processed by the 'dblclick' event // Cancel this event if doubleclick is fired. The actual previousTrack will be processed by the 'dblclick' event
if (e.detail > 1 ) { if (e.detail > 1 ) {
return; return;
} }
// Return to start of track, unless we are already (almost) at the beginning. In the latter case, continue and move // Return to start of track, unless we are already (almost) at the beginning. In the latter case, continue and move
// to the previous track, unless we are at the first track so no previous track exists. // to the previous track, unless we are at the first track so no previous track exists.
if (currentPlayer._currentTime >= 5 || playbackManager.getCurrentPlaylistIndex(currentPlayer) <= 1) { // currentTime is in msec.
if (playbackManager.currentTime(currentPlayer) >= 5 || playbackManager.getCurrentPlaylistIndex(currentPlayer) <= 1) {
playbackManager.seekPercent(0, currentPlayer); playbackManager.seekPercent(0, currentPlayer);
// This is done automatically by playbackManager, however, setting this here gives instant visual feedback. // This is done automatically by playbackManager, however, setting this here gives instant visual feedback.
// TODO: Check why seekPercentage doesn't reflect the changes inmmediately, so we can remove this workaround. // TODO: Check why seekPercent doesn't reflect the changes inmmediately, so we can remove this workaround.
positionSlider.value = 0; positionSlider.value = 0;
return; return;
} }
@ -574,7 +577,8 @@ function updateNowPlayingInfo(state) {
itemContextMenu.show(Object.assign({ itemContextMenu.show(Object.assign({
item: item, item: item,
user: user user: user
}, options)); }, options))
.catch(() => { /* no-op */ });
}); });
}); });
} }
@ -642,7 +646,8 @@ function hideNowPlayingBar() {
} }
function onPlaybackStopped(e, state) { function onPlaybackStopped(e, state) {
console.debug('nowplaying event: ' + e.type); console.debug('[nowPlayingBar:onPlaybackStopped] event: ' + e.type);
const player = this; const player = this;
if (player.isLocalPlayer) { if (player.isLocalPlayer) {
@ -669,7 +674,7 @@ function onStateChanged(event, state) {
return; return;
} }
console.debug('nowplaying event: ' + event.type); console.debug('[nowPlayingBar:onStateChanged] event: ' + event.type);
const player = this; const player = this;
if (!state.NowPlayingItem || layoutManager.tv || state.IsFullscreen === false) { if (!state.NowPlayingItem || layoutManager.tv || state.IsFullscreen === false) {
@ -792,4 +797,3 @@ document.addEventListener('viewbeforeshow', function (e) {
} }
} }
}); });

View file

@ -223,7 +223,8 @@ function updateNowPlayingInfo(context, state, serverId) {
itemContextMenu.show(Object.assign({ itemContextMenu.show(Object.assign({
item: fullItem, item: fullItem,
user: user user: user
}, options)); }, options))
.catch(() => { /* no-op */ });
}); });
}); });
}); });
@ -773,17 +774,20 @@ export default function () {
context.querySelector('.btnPreviousTrack').addEventListener('click', function (e) { context.querySelector('.btnPreviousTrack').addEventListener('click', function (e) {
if (currentPlayer) { if (currentPlayer) {
if (lastPlayerState.NowPlayingItem.MediaType === 'Audio') { if (playbackManager.isPlayingAudio(currentPlayer)) {
// Cancel this event if doubleclick is fired. The actual previousTrack will be processed by the 'dblclick' event // Cancel this event if doubleclick is fired. The actual previousTrack will be processed by the 'dblclick' event
if (e.detail > 1 ) { if (e.detail > 1 ) {
return; return;
} }
// Return to start of track, unless we are already (almost) at the beginning. In the latter case, continue and move // Return to start of track, unless we are already (almost) at the beginning. In the latter case, continue and move
// to the previous track, unless we are at the first track so no previous track exists. // to the previous track, unless we are at the first track so no previous track exists.
if (currentPlayer._currentTime >= 5 || playbackManager.getCurrentPlaylistIndex(currentPlayer) <= 1) { // currentTime is in msec.
if (playbackManager.currentTime(currentPlayer) >= 5 || playbackManager.getCurrentPlaylistIndex(currentPlayer) <= 1) {
playbackManager.seekPercent(0, currentPlayer); playbackManager.seekPercent(0, currentPlayer);
// This is done automatically by playbackManager, however, setting this here gives instant visual feedback. // This is done automatically by playbackManager, however, setting this here gives instant visual feedback.
// TODO: Check why seekPercentage doesn't reflect the changes inmmediately, so we can remove this workaround. // TODO: Check why seekPercent doesn't reflect the changes inmmediately, so we can remove this workaround.
positionSlider.value = 0; positionSlider.value = 0;
return; return;
} }

View file

@ -121,14 +121,15 @@ function showContextMenu(card, options) {
playlistId: playlistId, playlistId: playlistId,
collectionId: collectionId, collectionId: collectionId,
user: user user: user
}, options || {}))
}, options || {})).then(result => { .then(result => {
if (result.command === 'playallfromhere' || result.command === 'queueallfromhere') { if (result.command === 'playallfromhere' || result.command === 'queueallfromhere') {
executeAction(card, options.positionTo, result.command); executeAction(card, options.positionTo, result.command);
} else if (result.updated || result.deleted) { } else if (result.updated || result.deleted) {
notifyRefreshNeeded(card, options.itemsContainer); notifyRefreshNeeded(card, options.itemsContainer);
} }
}); })
.catch(() => { /* no-op */ });
}); });
}); });
}); });

View file

@ -1966,13 +1966,15 @@ export default function (view, params) {
selectedItem = item; selectedItem = item;
apiClient.getCurrentUser().then(function (user) { apiClient.getCurrentUser().then(function (user) {
itemContextMenu.show(getContextMenuOptions(selectedItem, user, button)).then(function (result) { itemContextMenu.show(getContextMenuOptions(selectedItem, user, button))
if (result.deleted) { .then(function (result) {
appRouter.goHome(); if (result.deleted) {
} else if (result.updated) { appRouter.goHome();
reload(self, view, params); } else if (result.updated) {
} reload(self, view, params);
}); }
})
.catch(() => { /* no-op */ });
}); });
}); });
} }

View file

@ -11,17 +11,20 @@ import { getItems } from '../../utils/jellyfin-apiclient/getItems.ts';
// Based on https://github.com/googlecast/CastVideos-chrome/blob/master/CastVideos.js // Based on https://github.com/googlecast/CastVideos-chrome/blob/master/CastVideos.js
let currentResolve;
let currentReject;
const PlayerName = 'Google Cast'; const PlayerName = 'Google Cast';
/*
* Some async CastSDK function are completed with callbacks.
* sendConnectionResult turns this into completion as a promise.
*/
let _currentResolve = null;
let _currentReject = null;
function sendConnectionResult(isOk) { function sendConnectionResult(isOk) {
const resolve = currentResolve; const resolve = _currentResolve;
const reject = currentReject; const reject = _currentReject;
currentResolve = null; _currentResolve = null;
currentReject = null; _currentReject = null;
if (isOk) { if (isOk) {
if (resolve) { if (resolve) {
@ -128,14 +131,14 @@ class CastPlayer {
*/ */
onInitSuccess() { onInitSuccess() {
this.isInitialized = true; this.isInitialized = true;
console.debug('chromecast init success'); console.debug('[chromecastPlayer] init success');
} }
/** /**
* Generic error callback function * Generic error callback function
*/ */
onError() { onError() {
console.debug('chromecast error'); console.debug('[chromecastPlayer] error');
} }
/** /**
@ -156,6 +159,7 @@ class CastPlayer {
} }
} }
// messageListener - receive callback messages from the Cast receiver
messageListener(namespace, message) { messageListener(namespace, message) {
if (typeof (message) === 'string') { if (typeof (message) === 'string') {
message = JSON.parse(message); message = JSON.parse(message);
@ -182,10 +186,10 @@ class CastPlayer {
*/ */
receiverListener(e) { receiverListener(e) {
if (e === 'available') { if (e === 'available') {
console.debug('chromecast receiver found'); console.debug('[chromecastPlayer] receiver found');
this.hasReceivers = true; this.hasReceivers = true;
} else { } else {
console.debug('chromecast receiver list empty'); console.debug('[chromecastPlayer] receiver list empty');
this.hasReceivers = false; this.hasReceivers = false;
} }
} }
@ -195,7 +199,7 @@ class CastPlayer {
*/ */
sessionUpdateListener(isAlive) { sessionUpdateListener(isAlive) {
if (isAlive) { if (isAlive) {
console.debug('sessionUpdateListener: already alive'); console.debug('[chromecastPlayer] sessionUpdateListener: already alive');
} else { } else {
this.session = null; this.session = null;
this.deviceState = DEVICE_STATE.IDLE; this.deviceState = DEVICE_STATE.IDLE;
@ -203,7 +207,7 @@ class CastPlayer {
document.removeEventListener('volumeupbutton', onVolumeUpKeyDown, false); document.removeEventListener('volumeupbutton', onVolumeUpKeyDown, false);
document.removeEventListener('volumedownbutton', onVolumeDownKeyDown, false); document.removeEventListener('volumedownbutton', onVolumeDownKeyDown, false);
console.debug('sessionUpdateListener: setting currentMediaSession to null'); console.debug('[chromecastPlayer] sessionUpdateListener: setting currentMediaSession to null');
this.currentMediaSession = null; this.currentMediaSession = null;
sendConnectionResult(false); sendConnectionResult(false);
@ -216,7 +220,7 @@ class CastPlayer {
* session request in opt_sessionRequest. * session request in opt_sessionRequest.
*/ */
launchApp() { launchApp() {
console.debug('chromecast launching app...'); console.debug('[chromecastPlayer] launching app...');
chrome.cast.requestSession(this.onRequestSessionSuccess.bind(this), this.onLaunchError.bind(this)); chrome.cast.requestSession(this.onRequestSessionSuccess.bind(this), this.onLaunchError.bind(this));
} }
@ -225,7 +229,7 @@ class CastPlayer {
* @param {Object} e A chrome.cast.Session object * @param {Object} e A chrome.cast.Session object
*/ */
onRequestSessionSuccess(e) { onRequestSessionSuccess(e) {
console.debug('chromecast session success: ' + e.sessionId); console.debug('[chromecastPlayer] session success: ' + e.sessionId);
this.onSessionConnected(e); this.onSessionConnected(e);
} }
@ -259,7 +263,7 @@ class CastPlayer {
* Callback function for launch error * Callback function for launch error
*/ */
onLaunchError() { onLaunchError() {
console.debug('chromecast launch error'); console.debug('[chromecastPlayer] launch error');
this.deviceState = DEVICE_STATE.ERROR; this.deviceState = DEVICE_STATE.ERROR;
sendConnectionResult(false); sendConnectionResult(false);
} }
@ -289,11 +293,12 @@ class CastPlayer {
/** /**
* Loads media into a running receiver application * Loads media into a running receiver application
* @param {Number} mediaIndex An index number to indicate current media content * @param {Number} mediaIndex - An index number to indicate current media content
* @returns Promise
*/ */
loadMedia(options, command) { loadMedia(options, command) {
if (!this.session) { if (!this.session) {
console.debug('no session'); console.debug('[chromecastPlayer] no session');
return Promise.reject(new Error('no session')); return Promise.reject(new Error('no session'));
} }
@ -385,7 +390,7 @@ class CastPlayer {
* @param {Object} mediaSession A new media object. * @param {Object} mediaSession A new media object.
*/ */
onMediaDiscovered(how, mediaSession) { onMediaDiscovered(how, mediaSession) {
console.debug('chromecast new media session ID:' + mediaSession.mediaSessionId + ' (' + how + ')'); console.debug('[chromecastPlayer] new media session ID:' + mediaSession.mediaSessionId + ' (' + how + ')');
this.currentMediaSession = mediaSession; this.currentMediaSession = mediaSession;
if (how === 'loadMedia') { if (how === 'loadMedia') {
@ -404,7 +409,7 @@ class CastPlayer {
* @param {!Boolean} e true/false * @param {!Boolean} e true/false
*/ */
onMediaStatusUpdate(e) { onMediaStatusUpdate(e) {
console.debug('chromecast updating media: ' + e); console.debug('[chromecastPlayer] updating media: ' + e);
if (e === false) { if (e === false) {
this.castPlayerState = PLAYER_STATE.IDLE; this.castPlayerState = PLAYER_STATE.IDLE;
} }
@ -498,12 +503,17 @@ function getItemsForPlayback(apiClient, query) {
} }
} }
/*
* relay castPlayer events to ChromecastPlayer events and include state info
*/
function bindEventForRelay(instance, eventName) { function bindEventForRelay(instance, eventName) {
Events.on(instance._castPlayer, eventName, function (e, data) { Events.on(instance._castPlayer, eventName, function (e, data) {
console.debug('cc: ' + eventName); console.debug('[chromecastPlayer] ' + eventName);
const state = instance.getPlayerStateInternal(data); // skip events without data
if (data?.ItemId) {
Events.trigger(instance, eventName, [state]); const state = instance.getPlayerStateInternal(data);
Events.trigger(instance, eventName, [state]);
}
}); });
} }
@ -519,30 +529,39 @@ function initializeChromecast() {
})); }));
Events.on(instance._castPlayer, 'connect', function () { Events.on(instance._castPlayer, 'connect', function () {
if (currentResolve) { if (_currentResolve) {
sendConnectionResult(true); sendConnectionResult(true);
} else { } else {
playbackManager.setActivePlayer(PlayerName, instance.getCurrentTargetInfo()); playbackManager.setActivePlayer(PlayerName, instance.getCurrentTargetInfo());
} }
console.debug('cc: connect'); console.debug('[chromecastPlayer] connect');
// Reset this so that statechange will fire // Reset this so that statechange will fire
instance.lastPlayerData = null; instance.lastPlayerData = null;
}); });
Events.on(instance._castPlayer, 'playbackstart', function (e, data) { Events.on(instance._castPlayer, 'playbackstart', function (e, data) {
console.debug('cc: playbackstart'); console.debug('[chromecastPlayer] playbackstart');
instance._castPlayer.initializeCastPlayer(); instance._castPlayer.initializeCastPlayer();
const state = instance.getPlayerStateInternal(data); const state = instance.getPlayerStateInternal(data);
Events.trigger(instance, 'playbackstart', [state]); Events.trigger(instance, 'playbackstart', [state]);
// be prepared that after this media item a next one may follow. See playbackManager
instance._playNextAfterEnded = true;
}); });
Events.on(instance._castPlayer, 'playbackstop', function (e, data) { Events.on(instance._castPlayer, 'playbackstop', function (e, data) {
console.debug('cc: playbackstop'); console.debug('[chromecastPlayer] playbackstop');
let state = instance.getPlayerStateInternal(data); let state = instance.getPlayerStateInternal(data);
if (!instance._playNextAfterEnded) {
// mark that no next media items are to be processed.
state.nextItem = null;
state.NextMediaType = null;
}
Events.trigger(instance, 'playbackstop', [state]); Events.trigger(instance, 'playbackstop', [state]);
state = instance.lastPlayerData.PlayState || {}; state = instance.lastPlayerData.PlayState || {};
@ -550,14 +569,16 @@ function initializeChromecast() {
const mute = state.IsMuted || false; const mute = state.IsMuted || false;
// Reset this so the next query doesn't make it appear like content is playing. // Reset this so the next query doesn't make it appear like content is playing.
instance.lastPlayerData = {}; instance.lastPlayerData = {
instance.lastPlayerData.PlayState = {}; PlayState: {
instance.lastPlayerData.PlayState.VolumeLevel = volume; VolumeLevel: volume,
instance.lastPlayerData.PlayState.IsMuted = mute; IsMuted: mute
}
};
}); });
Events.on(instance._castPlayer, 'playbackprogress', function (e, data) { Events.on(instance._castPlayer, 'playbackprogress', function (e, data) {
console.debug('cc: positionchange'); console.debug('[chromecastPlayer] positionchange');
const state = instance.getPlayerStateInternal(data); const state = instance.getPlayerStateInternal(data);
Events.trigger(instance, 'timeupdate', [state]); Events.trigger(instance, 'timeupdate', [state]);
@ -571,9 +592,10 @@ function initializeChromecast() {
bindEventForRelay(instance, 'shufflequeuemodechange'); bindEventForRelay(instance, 'shufflequeuemodechange');
Events.on(instance._castPlayer, 'playstatechange', function (e, data) { Events.on(instance._castPlayer, 'playstatechange', function (e, data) {
console.debug('cc: playstatechange'); console.debug('[chromecastPlayer] playstatechange');
const state = instance.getPlayerStateInternal(data);
// Updates the player and nowPlayingBar state to the current 'pause' state.
const state = instance.getPlayerStateInternal(data);
Events.trigger(instance, 'pause', [state]); Events.trigger(instance, 'pause', [state]);
}); });
} }
@ -598,19 +620,21 @@ class ChromecastPlayer {
}); });
} }
/*
* Cast button handling: select and connect to chromecast receiver
*/
tryPair() { tryPair() {
const castPlayer = this._castPlayer; const castPlayer = this._castPlayer;
if (castPlayer.deviceState !== DEVICE_STATE.ACTIVE && castPlayer.isInitialized) { if (castPlayer.deviceState !== DEVICE_STATE.ACTIVE && castPlayer.isInitialized) {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
currentResolve = resolve; _currentResolve = resolve;
currentReject = reject; _currentReject = reject;
castPlayer.launchApp(); castPlayer.launchApp();
}); });
} else { } else {
currentResolve = null; _currentResolve = null;
currentReject = null; _currentReject = null;
return Promise.reject(new Error('tryPair failed')); return Promise.reject(new Error('tryPair failed'));
} }
} }
@ -674,8 +698,6 @@ class ChromecastPlayer {
normalizeImages(data); normalizeImages(data);
console.debug(JSON.stringify(data));
if (triggerStateChange) { if (triggerStateChange) {
Events.trigger(this, 'statechange', [data]); Events.trigger(this, 'statechange', [data]);
} }
@ -824,6 +846,8 @@ class ChromecastPlayer {
} }
stop() { stop() {
// suppress playing a next media item after this one. See playbackManager
this._playNextAfterEnded = false;
return this._castPlayer.sendMessage({ return this._castPlayer.sendMessage({
options: {}, options: {},
command: 'Stop' command: 'Stop'
@ -1039,6 +1063,10 @@ class ChromecastPlayer {
this.playWithCommand(options, 'PlayNext'); this.playWithCommand(options, 'PlayNext');
} }
/*
* play
* options.items[]: Id, IsFolder, MediaType, Name, ServerId, Type, ...
*/
play(options) { play(options) {
if (options.items) { if (options.items) {
return this.playWithCommand(options, 'PlayNow'); return this.playWithCommand(options, 'PlayNow');
@ -1090,6 +1118,15 @@ class ChromecastPlayer {
getPlayerState() { getPlayerState() {
return this.getPlayerStateInternal() || {}; return this.getPlayerStateInternal() || {};
} }
getCurrentPlaylistIndex() {
// tbd: update to support playlists and not only album with tracks
return this.getPlayerStateInternal()?.NowPlayingItem?.IndexNumber;
}
clearQueue(currentTime) { // eslint-disable-line no-unused-vars
// not supported yet
}
} }
export default ChromecastPlayer; export default ChromecastPlayer;