2025-08-01 21:32:54 -07:00
// Sentinel values for specific list navigation
const TOP = 9999 ;
const BOTTOM = - 9999 ;
/ * *
* Get the CSRF token from the HTML document .
*
* @ returns { string } The CSRF token .
* /
function getCsrfToken ( ) {
return document . body . dataset . csrfToken || "" ;
}
2025-04-17 18:05:45 -07:00
/ * *
* Open a new tab with the given URL .
*
* @ param { string } url
* /
function openNewTab ( url ) {
const win = window . open ( "" ) ;
win . opener = null ;
win . location = url ;
win . focus ( ) ;
}
/ * *
* Scroll the page to the given element .
*
* @ param { Element } element
* @ param { boolean } evenIfOnScreen
* /
function scrollPageTo ( element , evenIfOnScreen ) {
const windowScrollPosition = window . scrollY ;
const windowHeight = document . documentElement . clientHeight ;
const viewportPosition = windowScrollPosition + windowHeight ;
const itemBottomPosition = element . offsetTop + element . offsetHeight ;
if ( evenIfOnScreen || viewportPosition - itemBottomPosition < 0 || viewportPosition - element . offsetTop > windowHeight ) {
window . scrollTo ( 0 , element . offsetTop - 10 ) ;
}
}
2025-08-01 21:32:54 -07:00
/ * *
* Attach a click event listener to elements matching the selector .
*
* @ param { string } selector
* @ param { function } callback
* @ param { boolean } noPreventDefault
* /
2019-07-17 20:27:39 -07:00
function onClick ( selector , callback , noPreventDefault ) {
2024-03-11 17:23:20 +01:00
document . querySelectorAll ( selector ) . forEach ( ( element ) => {
2019-07-17 20:27:39 -07:00
element . onclick = ( event ) => {
if ( ! noPreventDefault ) {
event . preventDefault ( ) ;
}
callback ( event ) ;
} ;
} ) ;
}
2025-08-01 21:32:54 -07:00
/ * *
* Attach an auxiliary click event listener to elements matching the selector .
*
* @ param { string } selector
* @ param { function } callback
* @ param { boolean } noPreventDefault
* /
2021-04-27 14:45:05 +02:00
function onAuxClick ( selector , callback , noPreventDefault ) {
2024-03-11 17:23:20 +01:00
document . querySelectorAll ( selector ) . forEach ( ( element ) => {
2021-04-27 14:45:05 +02:00
element . onauxclick = ( event ) => {
if ( ! noPreventDefault ) {
event . preventDefault ( ) ;
}
callback ( event ) ;
} ;
} ) ;
}
2025-08-01 21:32:54 -07:00
/ * *
* Filter visible elements based on the selector .
*
* @ param { string } selector
* @ returns { Array < Element > }
* /
function getVisibleElements ( selector ) {
const elements = document . querySelectorAll ( selector ) ;
return [ ... elements ] . filter ( ( element ) => element . offsetParent !== null ) ;
}
/ * *
* Get all visible entries on the current page .
*
* @ return { Array < Element > }
* /
function getVisibleEntries ( ) {
return getVisibleElements ( ".items .item" ) ;
}
/ * *
* Check if the current view is a list view .
*
* @ returns { boolean }
* /
function isListView ( ) {
return document . querySelector ( ".items" ) !== null ;
}
/ * *
* Check if the current view is an entry view .
*
* @ return { boolean }
* /
function isEntryView ( ) {
return document . querySelector ( "section.entry" ) !== null ;
}
/ * *
* Find the entry element for the given element .
*
* @ returns { Element | null }
* /
function findEntry ( element ) {
if ( isListView ( ) ) {
if ( element ) {
return element . closest ( ".item" ) ;
}
return document . querySelector ( ".current-item" ) ;
}
return document . querySelector ( ".entry" ) ;
}
/ * *
* Navigate to a specific page .
*
* @ param { string } page - The page to navigate to .
* @ param { boolean } reloadOnFail - If true , reload the current page if the target page is not found .
* /
function goToPage ( page , reloadOnFail = false ) {
const element = document . querySelector ( ":is(a, button)[data-page=" + page + "]" ) ;
if ( element ) {
document . location . href = element . href ;
} else if ( reloadOnFail ) {
window . location . reload ( ) ;
}
}
/ * *
* Navigate to the previous page .
*
* If the offset is a KeyboardEvent , it will navigate to the previous item in the list .
* If the offset is a number , it will jump that many items in the list .
* If the offset is TOP , it will jump to the first item in the list .
* If the offset is BOTTOM , it will jump to the last item in the list .
* If the current view is an entry view , it will redirect to the previous page .
*
* @ param { number | KeyboardEvent } offset - How many items to jump for focus .
* /
function goToPreviousPage ( offset ) {
if ( offset instanceof KeyboardEvent ) offset = - 1 ;
if ( isListView ( ) ) {
goToListItem ( offset ) ;
} else {
goToPage ( "previous" ) ;
}
}
/ * *
* Navigate to the next page .
*
* If the offset is a KeyboardEvent , it will navigate to the next item in the list .
* If the offset is a number , it will jump that many items in the list .
* If the offset is TOP , it will jump to the first item in the list .
* If the offset is BOTTOM , it will jump to the last item in the list .
* If the current view is an entry view , it will redirect to the next page .
*
* @ param { number | KeyboardEvent } offset - How many items to jump for focus .
* /
function goToNextPage ( offset ) {
if ( offset instanceof KeyboardEvent ) offset = 1 ;
if ( isListView ( ) ) {
goToListItem ( offset ) ;
} else {
goToPage ( "next" ) ;
}
}
/ * *
* Navigate to the individual feed or feeds page .
*
* If the current view is an entry view , it will redirect to the feed link of the entry .
* If the current view is a list view , it will redirect to the feeds page .
* /
function goToFeedOrFeedsPage ( ) {
if ( isEntryView ( ) ) {
goToFeedPage ( ) ;
} else {
goToPage ( "feeds" ) ;
}
}
/ * *
* Navigate to the feed page of the current entry .
*
* If the current view is an entry view , it will redirect to the feed link of the entry .
* If the current view is a list view , it will redirect to the feed link of the currently selected item .
* If no feed link is available , it will do nothing .
* /
function goToFeedPage ( ) {
if ( isEntryView ( ) ) {
const feedAnchor = document . querySelector ( "span.entry-website a" ) ;
if ( feedAnchor !== null ) {
window . location . href = feedAnchor . href ;
}
} else {
const currentItemFeed = document . querySelector ( ".current-item :is(a, button)[data-feed-link]" ) ;
if ( currentItemFeed !== null ) {
window . location . href = currentItemFeed . getAttribute ( "href" ) ;
}
}
}
/ * *
* Navigate to the add subscription page .
*
* @ returns { void }
* /
function goToAddSubscriptionPage ( ) {
window . location . href = document . body . dataset . addSubscriptionUrl ;
}
/ * *
* Navigate to the next or previous item in the list .
*
* If the offset is TOP , it will jump to the first item in the list .
* If the offset is BOTTOM , it will jump to the last item in the list .
* If the offset is a number , it will jump that many items in the list .
* If the current view is an entry view , it will redirect to the next or previous page .
*
* @ param { number } offset - How many items to jump for focus .
* @ return { void }
* /
function goToListItem ( offset ) {
const items = getVisibleEntries ( ) ;
if ( items . length === 0 ) {
return ;
}
if ( document . querySelector ( ".current-item" ) === null ) {
items [ 0 ] . classList . add ( "current-item" ) ;
items [ 0 ] . focus ( ) ;
return ;
}
for ( let i = 0 ; i < items . length ; i ++ ) {
if ( items [ i ] . classList . contains ( "current-item" ) ) {
items [ i ] . classList . remove ( "current-item" ) ;
// By default adjust selection by offset
let itemOffset = ( i + offset + items . length ) % items . length ;
// Allow jumping to top or bottom
if ( offset === TOP ) {
itemOffset = 0 ;
} else if ( offset === BOTTOM ) {
itemOffset = items . length - 1 ;
}
const item = items [ itemOffset ] ;
item . classList . add ( "current-item" ) ;
scrollPageTo ( item ) ;
item . focus ( ) ;
break ;
}
}
}
/ * *
* Handle the share action for the entry .
*
* If the share status is "shared" , it will trigger the Web Share API .
* If the share status is "share" , it will send an Ajax request to fetch the share URL and then trigger the Web Share API .
* If the Web Share API is not supported , it will redirect to the entry URL .
* /
async function handleShare ( ) {
const link = document . querySelector ( ':is(a, button)[data-share-status]' ) ;
const title = document . querySelector ( ".entry-header > h1 > a" ) ;
if ( link . dataset . shareStatus === "shared" ) {
await triggerWebShare ( title , link . href ) ;
}
else if ( link . dataset . shareStatus === "share" ) {
const request = new RequestBuilder ( link . href ) ;
request . withCallback ( ( r ) => {
// Ensure title is not null before passing to triggerWebShare
triggerWebShare ( title , r . url ) ;
} ) ;
request . withHttpMethod ( "GET" ) ;
request . execute ( ) ;
}
}
/ * *
* Trigger the Web Share API to share the entry .
*
* If the Web Share API is not supported , it will redirect to the entry URL .
*
* @ param { Element } title - The title element of the entry .
* @ param { string } url - The URL of the entry to share .
* /
async function triggerWebShare ( title , url ) {
if ( ! navigator . canShare ) {
console . error ( "Your browser doesn't support the Web Share API." ) ;
window . location = url ;
return ;
}
try {
await navigator . share ( {
title : title ? title . textContent : url ,
url : url
} ) ;
} catch ( err ) {
console . error ( err ) ;
}
window . location . reload ( ) ;
}
2024-02-07 16:54:11 +08:00
// make logo element as button on mobile layout
function checkMenuToggleModeByLayout ( ) {
const logoElement = document . querySelector ( ".logo" ) ;
2024-03-10 20:32:39 -07:00
if ( ! logoElement ) return ;
2024-02-07 16:54:11 +08:00
2024-03-11 17:23:20 +01:00
const homePageLinkElement = document . querySelector ( ".logo > a" ) ;
2024-02-07 16:54:11 +08:00
if ( document . documentElement . clientWidth < 620 ) {
2024-03-11 17:23:20 +01:00
const navMenuElement = document . getElementById ( "header-menu" ) ;
const navMenuElementIsExpanded = navMenuElement . classList . contains ( "js-menu-show" ) ;
const logoToggleButtonLabel = logoElement . getAttribute ( "data-toggle-button-label" ) ;
2024-02-07 16:54:11 +08:00
logoElement . setAttribute ( "role" , "button" ) ;
logoElement . setAttribute ( "tabindex" , "0" ) ;
2024-03-10 20:32:39 -07:00
logoElement . setAttribute ( "aria-label" , logoToggleButtonLabel ) ;
2024-03-11 17:23:20 +01:00
logoElement . setAttribute ( "aria-expanded" , navMenuElementIsExpanded ? "true" : "false" ) ;
2024-03-10 20:32:39 -07:00
homePageLinkElement . setAttribute ( "tabindex" , "-1" ) ;
2024-02-07 16:54:11 +08:00
} else {
logoElement . removeAttribute ( "role" ) ;
logoElement . removeAttribute ( "tabindex" ) ;
logoElement . removeAttribute ( "aria-expanded" ) ;
2024-03-10 20:32:39 -07:00
logoElement . removeAttribute ( "aria-label" ) ;
2024-02-07 16:54:11 +08:00
homePageLinkElement . removeAttribute ( "tabindex" ) ;
}
}
2024-02-14 17:07:56 +08:00
function fixVoiceOverDetailsSummaryBug ( ) {
2024-03-11 17:23:20 +01:00
document . querySelectorAll ( "details" ) . forEach ( ( details ) => {
2024-03-10 20:32:39 -07:00
const summaryElement = details . querySelector ( "summary" ) ;
summaryElement . setAttribute ( "role" , "button" ) ;
2024-03-11 17:23:20 +01:00
summaryElement . setAttribute ( "aria-expanded" , details . open ? "true" : "false" ) ;
2024-02-14 17:07:56 +08:00
details . addEventListener ( "toggle" , ( ) => {
2024-03-11 17:23:20 +01:00
summaryElement . setAttribute ( "aria-expanded" , details . open ? "true" : "false" ) ;
2024-03-10 20:32:39 -07:00
} ) ;
} ) ;
2024-02-14 17:07:56 +08:00
}
2019-07-17 20:27:39 -07:00
// Show and hide the main menu on mobile devices.
2024-02-07 16:54:11 +08:00
function toggleMainMenu ( event ) {
if ( event . type === "keydown" && ! ( event . key === "Enter" || event . key === " " ) ) {
2024-03-10 20:32:39 -07:00
return ;
2024-02-07 16:54:11 +08:00
}
2024-03-10 20:32:39 -07:00
2024-02-07 16:54:11 +08:00
if ( event . currentTarget . getAttribute ( "role" ) ) {
2024-03-10 20:32:39 -07:00
event . preventDefault ( ) ;
2024-02-07 16:54:11 +08:00
}
2024-03-11 17:23:20 +01:00
const menu = document . querySelector ( ".header nav ul" ) ;
const menuToggleButton = document . querySelector ( ".logo" ) ;
2024-01-25 16:36:10 +08:00
if ( menu . classList . contains ( "js-menu-show" ) ) {
2025-01-20 16:17:58 +01:00
menuToggleButton . setAttribute ( "aria-expanded" , "false" ) ;
2019-07-17 20:27:39 -07:00
} else {
2025-01-20 16:17:58 +01:00
menuToggleButton . setAttribute ( "aria-expanded" , "true" ) ;
2019-07-17 20:27:39 -07:00
}
2025-01-12 20:54:08 +00:00
menu . classList . toggle ( "js-menu-show" ) ;
2019-07-17 20:27:39 -07:00
}
// Handle click events for the main menu (<li> and <a>).
function onClickMainMenuListItem ( event ) {
2024-03-11 17:23:20 +01:00
const element = event . target ;
2019-07-17 20:27:39 -07:00
if ( element . tagName === "A" ) {
window . location . href = element . getAttribute ( "href" ) ;
} else {
2024-04-08 23:40:58 +02:00
const linkElement = element . querySelector ( "a" ) || element . closest ( "a" ) ;
window . location . href = linkElement . getAttribute ( "href" ) ;
2019-07-17 20:27:39 -07:00
}
}
2025-08-01 20:50:00 -07:00
/ * *
* This function changes the button label to the loading state and disables the button .
*
* @ returns { void }
* /
function disableSubmitButtonsOnFormSubmit ( ) {
2024-03-11 17:23:20 +01:00
document . querySelectorAll ( "form" ) . forEach ( ( element ) => {
2019-07-17 20:27:39 -07:00
element . onsubmit = ( ) => {
2025-08-01 20:50:00 -07:00
const buttons = element . querySelectorAll ( "button[type=submit]" ) ;
buttons . forEach ( ( button ) => {
if ( button . dataset . labelLoading ) {
button . textContent = button . dataset . labelLoading ;
}
2019-07-17 20:27:39 -07:00
button . disabled = true ;
2025-08-01 20:50:00 -07:00
} ) ;
2019-07-17 20:27:39 -07:00
} ;
} ) ;
}
// Show modal dialog with the list of keyboard shortcuts.
function showKeyboardShortcuts ( ) {
2024-03-11 17:23:20 +01:00
const template = document . getElementById ( "keyboard-shortcuts" ) ;
2025-06-20 14:51:48 +02:00
ModalHandler . open ( template . content , "dialog-title" ) ;
2019-07-17 20:27:39 -07:00
}
// Mark as read visible items of the current page.
function markPageAsRead ( ) {
2025-08-01 20:10:44 -07:00
const items = getVisibleEntries ( ) ;
2024-03-11 17:23:20 +01:00
const entryIDs = [ ] ;
2019-07-17 20:27:39 -07:00
items . forEach ( ( element ) => {
element . classList . add ( "item-status-read" ) ;
entryIDs . push ( parseInt ( element . dataset . id , 10 ) ) ;
} ) ;
if ( entryIDs . length > 0 ) {
updateEntriesStatus ( entryIDs , "read" , ( ) => {
// Make sure the Ajax request reach the server before we reload the page.
2024-03-11 17:23:20 +01:00
const element = document . querySelector ( ":is(a, button)[data-action=markPageAsRead]" ) ;
2019-07-17 20:27:39 -07:00
let showOnlyUnread = false ;
if ( element ) {
2019-07-17 21:07:29 -07:00
showOnlyUnread = element . dataset . showOnlyUnread || false ;
2019-07-17 20:27:39 -07:00
}
if ( showOnlyUnread ) {
2021-03-04 17:13:51 +01:00
window . location . href = window . location . href ;
2019-07-17 20:27:39 -07:00
} else {
goToPage ( "next" , true ) ;
}
} ) ;
}
}
2022-01-31 22:57:14 -05:00
/ * *
* Handle entry status changes from the list view and entry view .
* Focus the next or the previous entry if it exists .
2025-08-01 20:10:44 -07:00
*
* @ param { string } navigationDirection Navigation direction : "previous" or "next" .
* @ param { Element } element Element that triggered the action .
* @ param { boolean } setToRead If true , set the entry to read instead of toggling the status .
* @ returns { void }
2022-01-31 22:57:14 -05:00
* /
2025-08-01 20:10:44 -07:00
function handleEntryStatus ( navigationDirection , element , setToRead ) {
2024-03-11 17:23:20 +01:00
const toasting = ! element ;
const currentEntry = findEntry ( element ) ;
2025-08-01 20:10:44 -07:00
2022-01-29 16:53:10 -05:00
if ( currentEntry ) {
2025-01-17 01:50:09 +00:00
if ( ! setToRead || currentEntry . querySelector ( ":is(a, button)[data-toggle-status]" ) . dataset . value === "unread" ) {
2022-01-29 16:53:10 -05:00
toggleEntryStatus ( currentEntry , toasting ) ;
}
if ( isListView ( ) && currentEntry . classList . contains ( 'current-item' ) ) {
2025-08-01 20:10:44 -07:00
switch ( navigationDirection ) {
2024-03-10 20:52:56 -07:00
case "previous" :
goToListItem ( - 1 ) ;
break ;
case "next" :
goToListItem ( 1 ) ;
break ;
2022-01-31 22:57:14 -05:00
}
2022-01-29 16:53:10 -05:00
}
}
}
2024-03-15 20:19:38 -07:00
// Add an icon-label span element.
function appendIconLabel ( element , labelTextContent ) {
2024-03-13 18:20:45 +01:00
const span = document . createElement ( 'span' ) ;
span . classList . add ( 'icon-label' ) ;
2024-03-15 20:19:38 -07:00
span . textContent = labelTextContent ;
2024-03-13 18:20:45 +01:00
element . appendChild ( span ) ;
}
2019-07-17 20:27:39 -07:00
// Change the entry status to the opposite value.
2019-10-07 11:55:15 +08:00
function toggleEntryStatus ( element , toasting ) {
2024-03-11 17:23:20 +01:00
const entryID = parseInt ( element . dataset . id , 10 ) ;
const link = element . querySelector ( ":is(a, button)[data-toggle-status]" ) ;
2025-08-02 10:50:00 -07:00
if ( ! link ) {
return ;
}
2019-07-17 20:27:39 -07:00
2024-03-11 17:23:20 +01:00
const currentStatus = link . dataset . value ;
const newStatus = currentStatus === "read" ? "unread" : "read" ;
2019-07-17 20:27:39 -07:00
2024-03-11 01:16:36 +01:00
link . querySelector ( "span" ) . textContent = link . dataset . labelLoading ;
2021-06-12 20:00:05 +00:00
updateEntriesStatus ( [ entryID ] , newStatus , ( ) => {
let iconElement , label ;
if ( currentStatus === "read" ) {
iconElement = document . querySelector ( "template#icon-read" ) ;
label = link . dataset . labelRead ;
if ( toasting ) {
showToast ( link . dataset . toastUnread , iconElement ) ;
}
} else {
iconElement = document . querySelector ( "template#icon-unread" ) ;
label = link . dataset . labelUnread ;
if ( toasting ) {
showToast ( link . dataset . toastRead , iconElement ) ;
}
2019-10-07 11:55:15 +08:00
}
2019-07-17 20:27:39 -07:00
2024-03-14 12:56:48 +01:00
link . replaceChildren ( iconElement . content . cloneNode ( true ) ) ;
2024-03-15 20:19:38 -07:00
appendIconLabel ( link , label ) ;
2021-06-12 20:00:05 +00:00
link . dataset . value = newStatus ;
2020-12-29 20:47:18 -08:00
2021-06-12 20:00:05 +00:00
if ( element . classList . contains ( "item-status-" + currentStatus ) ) {
element . classList . remove ( "item-status-" + currentStatus ) ;
element . classList . add ( "item-status-" + newStatus ) ;
}
2025-08-01 20:10:44 -07:00
if ( isListView ( ) && getVisibleEntries ( ) . length === 0 ) {
window . location . reload ( ) ;
}
2021-06-12 20:00:05 +00:00
} ) ;
2019-07-17 20:27:39 -07:00
}
// Mark a single entry as read.
function markEntryAsRead ( element ) {
if ( element . classList . contains ( "item-status-unread" ) ) {
element . classList . remove ( "item-status-unread" ) ;
element . classList . add ( "item-status-read" ) ;
2024-03-11 17:23:20 +01:00
const entryID = parseInt ( element . dataset . id , 10 ) ;
2019-07-17 20:27:39 -07:00
updateEntriesStatus ( [ entryID ] , "read" ) ;
}
}
2020-05-27 06:35:44 +02:00
// Send the Ajax request to refresh all feeds in the background
function handleRefreshAllFeeds ( ) {
2024-03-11 17:23:20 +01:00
const url = document . body . dataset . refreshAllFeedsUrl ;
2023-10-18 19:57:02 -07:00
if ( url ) {
window . location . href = url ;
}
2020-05-27 06:35:44 +02:00
}
2019-07-17 20:27:39 -07:00
// Send the Ajax request to change entries statuses.
function updateEntriesStatus ( entryIDs , status , callback ) {
2024-03-11 17:23:20 +01:00
const url = document . body . dataset . entriesStatusUrl ;
const request = new RequestBuilder ( url ) ;
2024-03-10 20:52:56 -07:00
request . withBody ( { entry _ids : entryIDs , status : status } ) ;
2021-06-03 02:39:47 +02:00
request . withCallback ( ( resp ) => {
resp . json ( ) . then ( count => {
2023-08-01 06:11:39 +02:00
if ( callback ) {
callback ( resp ) ;
}
2019-07-17 20:27:39 -07:00
2021-06-03 02:39:47 +02:00
if ( status === "read" ) {
decrementUnreadCounter ( count ) ;
} else {
incrementUnreadCounter ( count ) ;
}
} ) ;
} ) ;
request . execute ( ) ;
2019-07-17 20:27:39 -07:00
}
// Handle save entry from list view and entry view.
2019-07-26 10:02:39 +08:00
function handleSaveEntry ( element ) {
2024-03-11 17:23:20 +01:00
const toasting = ! element ;
const currentEntry = findEntry ( element ) ;
2019-07-26 10:02:39 +08:00
if ( currentEntry ) {
Replace link has button role with button tag
# Change HTML tag to button
Replace the link tag with an HTML button to prevent some screen readers from having confusing announcements. By using the HTML button, users can use the Enter and Space keys to activate actions by default, instead of implementing them in JavaScript.
# Differentiate links and buttons visually
When activating the link element, the user may expect the web page to navigate to the URL and the page will refresh; when activating the button element, the user may expect the web page to still be on the same page, so that their current state, such as: input value, won't disappear.
Links and buttons should have different styles visually, so that users can't expect what will happen when they activate a link or a button.
I added the underline to the links, because that is the common pattern. Buttons have border and background color in a common pattern. But I think that will change the current layout drastically. So I added the focus, hover and active classes to the buttons instead.
2024-02-10 09:09:30 +08:00
saveEntry ( currentEntry . querySelector ( ":is(a, button)[data-save-entry]" ) , toasting ) ;
2019-07-17 20:27:39 -07:00
}
}
// Send the Ajax request to save an entry.
2019-10-07 11:55:15 +08:00
function saveEntry ( element , toasting ) {
2024-03-11 17:23:20 +01:00
if ( ! element || element . dataset . completed ) {
2019-07-17 20:27:39 -07:00
return ;
}
2024-03-13 18:20:45 +01:00
element . textContent = "" ;
2024-03-15 20:19:38 -07:00
appendIconLabel ( element , element . dataset . labelLoading ) ;
2019-07-17 20:27:39 -07:00
2024-03-11 17:23:20 +01:00
const request = new RequestBuilder ( element . dataset . saveUrl ) ;
2019-07-17 20:27:39 -07:00
request . withCallback ( ( ) => {
2024-03-13 18:20:45 +01:00
element . textContent = "" ;
2024-03-15 20:19:38 -07:00
appendIconLabel ( element , element . dataset . labelDone ) ;
2025-01-20 16:17:58 +01:00
element . dataset . completed = "true" ;
2019-10-07 11:55:15 +08:00
if ( toasting ) {
2024-03-11 17:23:20 +01:00
const iconElement = document . querySelector ( "template#icon-save" ) ;
2021-03-07 11:55:43 -08:00
showToast ( element . dataset . toastDone , iconElement ) ;
2019-10-07 11:55:15 +08:00
}
2019-07-17 20:27:39 -07:00
} ) ;
request . execute ( ) ;
}
// Handle bookmark from the list view and entry view.
2019-07-26 10:02:39 +08:00
function handleBookmark ( element ) {
2024-03-11 17:23:20 +01:00
const toasting = ! element ;
const currentEntry = findEntry ( element ) ;
2019-07-26 10:02:39 +08:00
if ( currentEntry ) {
2019-10-07 11:55:15 +08:00
toggleBookmark ( currentEntry , toasting ) ;
2019-07-17 20:27:39 -07:00
}
}
// Send the Ajax request and change the icon when bookmarking an entry.
2019-10-07 11:55:15 +08:00
function toggleBookmark ( parentElement , toasting ) {
2024-03-15 20:19:38 -07:00
const buttonElement = parentElement . querySelector ( ":is(a, button)[data-toggle-bookmark]" ) ;
if ( ! buttonElement ) {
2019-07-17 20:27:39 -07:00
return ;
}
2024-03-15 20:19:38 -07:00
buttonElement . textContent = "" ;
appendIconLabel ( buttonElement , buttonElement . dataset . labelLoading ) ;
2019-07-17 20:27:39 -07:00
2024-03-15 20:19:38 -07:00
const request = new RequestBuilder ( buttonElement . dataset . bookmarkUrl ) ;
2019-07-17 20:27:39 -07:00
request . withCallback ( ( ) => {
2024-03-15 20:19:38 -07:00
const currentStarStatus = buttonElement . dataset . value ;
2024-03-11 17:23:20 +01:00
const newStarStatus = currentStarStatus === "star" ? "unstar" : "star" ;
2020-12-29 20:47:18 -08:00
2021-03-07 11:55:43 -08:00
let iconElement , label ;
2020-12-29 20:47:18 -08:00
if ( currentStarStatus === "star" ) {
2021-03-07 11:55:43 -08:00
iconElement = document . querySelector ( "template#icon-star" ) ;
2024-03-15 20:19:38 -07:00
label = buttonElement . dataset . labelStar ;
2019-10-07 11:55:15 +08:00
if ( toasting ) {
2024-03-15 20:19:38 -07:00
showToast ( buttonElement . dataset . toastUnstar , iconElement ) ;
2019-10-07 11:55:15 +08:00
}
2019-07-17 20:27:39 -07:00
} else {
2021-03-07 11:55:43 -08:00
iconElement = document . querySelector ( "template#icon-unstar" ) ;
2024-03-15 20:19:38 -07:00
label = buttonElement . dataset . labelUnstar ;
2019-10-07 11:55:15 +08:00
if ( toasting ) {
2024-03-15 20:19:38 -07:00
showToast ( buttonElement . dataset . toastStar , iconElement ) ;
2019-10-07 11:55:15 +08:00
}
2019-07-17 20:27:39 -07:00
}
2020-12-29 20:47:18 -08:00
2024-03-15 20:19:38 -07:00
buttonElement . replaceChildren ( iconElement . content . cloneNode ( true ) ) ;
appendIconLabel ( buttonElement , label ) ;
buttonElement . dataset . value = newStarStatus ;
2019-07-17 20:27:39 -07:00
} ) ;
request . execute ( ) ;
}
// Send the Ajax request to download the original web page.
function handleFetchOriginalContent ( ) {
if ( isListView ( ) ) {
return ;
}
2024-03-15 20:19:38 -07:00
const buttonElement = document . querySelector ( ":is(a, button)[data-fetch-content-entry]" ) ;
if ( ! buttonElement ) {
2019-07-17 20:27:39 -07:00
return ;
}
2024-03-15 20:19:38 -07:00
const previousElement = buttonElement . cloneNode ( true ) ;
buttonElement . textContent = "" ;
appendIconLabel ( buttonElement , buttonElement . dataset . labelLoading ) ;
2019-07-17 20:27:39 -07:00
2024-03-15 20:19:38 -07:00
const request = new RequestBuilder ( buttonElement . dataset . fetchContentUrl ) ;
2019-07-17 20:27:39 -07:00
request . withCallback ( ( response ) => {
2024-03-15 20:19:38 -07:00
buttonElement . textContent = '' ;
buttonElement . appendChild ( previousElement ) ;
2019-07-17 20:27:39 -07:00
response . json ( ) . then ( ( data ) => {
2022-12-14 13:32:45 +01:00
if ( data . hasOwnProperty ( "content" ) && data . hasOwnProperty ( "reading_time" ) ) {
2024-03-18 00:45:41 +01:00
document . querySelector ( ".entry-content" ) . innerHTML = ttpolicy . createHTML ( data . content ) ;
2024-03-11 17:23:20 +01:00
const entryReadingtimeElement = document . querySelector ( ".entry-reading-time" ) ;
2023-10-06 17:15:09 -07:00
if ( entryReadingtimeElement ) {
2024-03-14 12:56:48 +01:00
entryReadingtimeElement . textContent = data . reading _time ;
2023-10-06 17:15:09 -07:00
}
2019-07-17 20:27:39 -07:00
}
} ) ;
} ) ;
request . execute ( ) ;
}
2019-11-29 13:48:56 -08:00
function openOriginalLink ( openLinkInCurrentTab ) {
2024-03-11 17:23:20 +01:00
const entryLink = document . querySelector ( ".entry h1 a" ) ;
2019-07-17 20:27:39 -07:00
if ( entryLink !== null ) {
2019-11-29 13:48:56 -08:00
if ( openLinkInCurrentTab ) {
window . location . href = entryLink . getAttribute ( "href" ) ;
} else {
2025-04-17 18:05:45 -07:00
openNewTab ( entryLink . getAttribute ( "href" ) ) ;
2019-11-29 13:48:56 -08:00
}
2019-07-17 20:27:39 -07:00
return ;
}
2024-03-11 17:23:20 +01:00
const currentItemOriginalLink = document . querySelector ( ".current-item :is(a, button)[data-original-link]" ) ;
2019-07-17 20:27:39 -07:00
if ( currentItemOriginalLink !== null ) {
2025-04-17 18:05:45 -07:00
openNewTab ( currentItemOriginalLink . getAttribute ( "href" ) ) ;
2019-07-17 20:27:39 -07:00
2024-03-11 17:23:20 +01:00
const currentItem = document . querySelector ( ".current-item" ) ;
2020-01-13 14:57:31 -06:00
// If we are not on the list of starred items, move to the next item
2025-01-17 01:50:09 +00:00
if ( document . location . href !== document . querySelector ( ':is(a, button)[data-page=starred]' ) . href ) {
2022-01-31 22:57:14 -05:00
goToListItem ( 1 ) ;
2020-01-13 14:57:31 -06:00
}
2019-07-17 20:27:39 -07:00
markEntryAsRead ( currentItem ) ;
}
}
2020-01-07 00:02:02 -06:00
function openCommentLink ( openLinkInCurrentTab ) {
if ( ! isListView ( ) ) {
2024-03-11 17:23:20 +01:00
const entryLink = document . querySelector ( ":is(a, button)[data-comments-link]" ) ;
2020-01-07 00:02:02 -06:00
if ( entryLink !== null ) {
if ( openLinkInCurrentTab ) {
window . location . href = entryLink . getAttribute ( "href" ) ;
} else {
2025-04-17 18:05:45 -07:00
openNewTab ( entryLink . getAttribute ( "href" ) ) ;
2020-01-07 00:02:02 -06:00
}
}
} else {
2024-03-11 17:23:20 +01:00
const currentItemCommentsLink = document . querySelector ( ".current-item :is(a, button)[data-comments-link]" ) ;
2020-01-07 00:02:02 -06:00
if ( currentItemCommentsLink !== null ) {
2025-04-17 18:05:45 -07:00
openNewTab ( currentItemCommentsLink . getAttribute ( "href" ) ) ;
2020-01-07 00:02:02 -06:00
}
}
}
2019-07-17 20:27:39 -07:00
function openSelectedItem ( ) {
2024-03-11 17:23:20 +01:00
const currentItemLink = document . querySelector ( ".current-item .item-title a" ) ;
2019-07-17 20:27:39 -07:00
if ( currentItemLink !== null ) {
window . location . href = currentItemLink . getAttribute ( "href" ) ;
}
}
function unsubscribeFromFeed ( ) {
2024-03-11 17:23:20 +01:00
const unsubscribeLinks = document . querySelectorAll ( "[data-action=remove-feed]" ) ;
2019-07-17 20:27:39 -07:00
if ( unsubscribeLinks . length === 1 ) {
2024-03-11 17:23:20 +01:00
const unsubscribeLink = unsubscribeLinks [ 0 ] ;
2019-07-17 20:27:39 -07:00
2024-03-11 17:23:20 +01:00
const request = new RequestBuilder ( unsubscribeLink . dataset . url ) ;
2019-07-17 20:27:39 -07:00
request . withCallback ( ( ) => {
if ( unsubscribeLink . dataset . redirectUrl ) {
window . location . href = unsubscribeLink . dataset . redirectUrl ;
} else {
window . location . reload ( ) ;
}
} ) ;
request . execute ( ) ;
}
}
2020-10-16 17:44:03 -05:00
function scrollToCurrentItem ( ) {
2024-03-11 17:23:20 +01:00
const currentItem = document . querySelector ( ".current-item" ) ;
2020-10-16 17:44:03 -05:00
if ( currentItem !== null ) {
2025-04-17 18:05:45 -07:00
scrollPageTo ( currentItem , true ) ;
2020-10-16 17:44:03 -05:00
}
}
2019-07-17 20:27:39 -07:00
function decrementUnreadCounter ( n ) {
updateUnreadCounterValue ( ( current ) => {
return current - n ;
} ) ;
}
function incrementUnreadCounter ( n ) {
updateUnreadCounterValue ( ( current ) => {
return current + n ;
} ) ;
}
function updateUnreadCounterValue ( callback ) {
2024-03-11 17:23:20 +01:00
document . querySelectorAll ( "span.unread-counter" ) . forEach ( ( element ) => {
const oldValue = parseInt ( element . textContent , 10 ) ;
2024-03-14 12:56:48 +01:00
element . textContent = callback ( oldValue ) ;
2019-07-17 20:27:39 -07:00
} ) ;
if ( window . location . href . endsWith ( '/unread' ) ) {
2024-03-11 17:23:20 +01:00
const oldValue = parseInt ( document . title . split ( '(' ) [ 1 ] , 10 ) ;
const newValue = callback ( oldValue ) ;
2019-07-17 20:27:39 -07:00
document . title = document . title . replace (
/(.*?)\(\d+\)(.*?)/ ,
function ( match , prefix , suffix , offset , string ) {
return prefix + '(' + newValue + ')' + suffix ;
}
) ;
}
}
2019-07-17 21:07:29 -07:00
function handleConfirmationMessage ( linkElement , callback ) {
2025-01-17 01:50:09 +00:00
if ( linkElement . tagName !== 'A' && linkElement . tagName !== "BUTTON" ) {
2020-06-14 19:00:41 -07:00
linkElement = linkElement . parentNode ;
}
2019-07-17 21:07:29 -07:00
2020-06-14 19:00:41 -07:00
linkElement . style . display = "none" ;
2023-08-01 06:11:39 +02:00
2024-03-11 17:23:20 +01:00
const containerElement = linkElement . parentNode ;
const questionElement = document . createElement ( "span" ) ;
2019-07-17 21:07:29 -07:00
2023-08-08 14:12:41 +00:00
function createLoadingElement ( ) {
2024-03-11 17:23:20 +01:00
const loadingElement = document . createElement ( "span" ) ;
2019-07-17 21:07:29 -07:00
loadingElement . className = "loading" ;
loadingElement . appendChild ( document . createTextNode ( linkElement . dataset . labelLoading ) ) ;
questionElement . remove ( ) ;
containerElement . appendChild ( loadingElement ) ;
2023-08-08 14:12:41 +00:00
}
2024-03-11 17:23:20 +01:00
const yesElement = document . createElement ( "button" ) ;
2023-08-08 14:12:41 +00:00
yesElement . appendChild ( document . createTextNode ( linkElement . dataset . labelYes ) ) ;
yesElement . onclick = ( event ) => {
event . preventDefault ( ) ;
createLoadingElement ( ) ;
2019-07-17 21:07:29 -07:00
callback ( linkElement . dataset . url , linkElement . dataset . redirectUrl ) ;
} ;
2024-03-11 17:23:20 +01:00
const noElement = document . createElement ( "button" ) ;
2019-07-17 21:07:29 -07:00
noElement . appendChild ( document . createTextNode ( linkElement . dataset . labelNo ) ) ;
noElement . onclick = ( event ) => {
event . preventDefault ( ) ;
2023-08-08 14:12:41 +00:00
const noActionUrl = linkElement . dataset . noActionUrl ;
if ( noActionUrl ) {
createLoadingElement ( ) ;
callback ( noActionUrl , linkElement . dataset . redirectUrl ) ;
} else {
linkElement . style . display = "inline" ;
questionElement . remove ( ) ;
}
2019-07-17 21:07:29 -07:00
} ;
questionElement . className = "confirm" ;
questionElement . appendChild ( document . createTextNode ( linkElement . dataset . labelQuestion + " " ) ) ;
questionElement . appendChild ( yesElement ) ;
questionElement . appendChild ( document . createTextNode ( ", " ) ) ;
questionElement . appendChild ( noElement ) ;
containerElement . appendChild ( questionElement ) ;
}
2019-10-07 11:55:15 +08:00
2021-03-07 11:55:43 -08:00
function showToast ( label , iconElement ) {
if ( ! label || ! iconElement ) {
return ;
}
const toastMsgElement = document . getElementById ( "toast-msg" ) ;
2025-06-20 14:51:48 +02:00
toastMsgElement . replaceChildren ( iconElement . content . cloneNode ( true ) ) ;
appendIconLabel ( toastMsgElement , label ) ;
const toastElementWrapper = document . getElementById ( "toast-wrapper" ) ;
toastElementWrapper . classList . remove ( 'toast-animate' ) ;
setTimeout ( ( ) => {
toastElementWrapper . classList . add ( 'toast-animate' ) ;
} , 100 ) ;
2019-10-07 11:55:15 +08:00
}
2022-02-27 21:33:53 -05:00
Add Media Player and resume to last playback position
In order to ease podcast listening, the player can be put on top of the feed entry as main content.
Use the `Use podcast player` option to enable that. It works on audio and video.
Also, when playing audio or video, progression will be saved in order to be able to resume listening later.
This position saving is done using the original attachement/enclosures player AND podcast player and do not rely on
the podcast player option ti be enabled.
Additionally, I made the player fill the width with the entry container to ease seeking and have a bigger video.
updateEnclosures now keep existing enclosures based on URL
When feeds get updated, enclosures entries are always wiped and re-created. This cause two issue
- enclosure progression get lost in the process
- enclosure ID changes
I used the URL as identifier of an enclosure. Not perfect but hopefully should work.
When an enclosure already exist, I simply do nothing and leave the entry as is in the database.
If anyone is listening/watching to this enclosure during the refresh, the id stay coherent and progression saving still works.
The updateEnclosures function got a bit more complex. I tried to make it the more clear I could.
Some optimisation are possible but would make the function harder to read in my opinion.
I'm not sure if this is often the case, but some feeds may include tracking or simply change the url each
time we update the feed. In those situation, enclosures ids and progression will be lost.
I have no idea how to handle this last situation. Use the size instead/alongside url to define the identity of an enclosure ?
Translation: english as placeholder for every language except French
Aside, I tested a video feed and fixed a few things for it. In fact, the MimeType was not working
at all on my side, and found a pretty old stackoverflow discussion that suggest to use an Apple non-standard MimeType for
m4v video format. I only did one substitution because I only have one feed to test. Any new video feed can make this go away
or evolve depending on the situation. Real video feeds does not tend to be easy to find and test extensively this.
Co-authored-by: toastal
2023-04-13 11:46:43 +02:00
/ * *
* save player position to allow to resume playback later
* @ param { Element } playerElement
* /
2024-07-28 21:29:45 +02:00
function handlePlayerProgressionSaveAndMarkAsReadOnCompletion ( playerElement ) {
if ( ! isPlayerPlaying ( playerElement ) ) {
return ; //If the player is not playing, we do not want to save the progression and mark as read on completion
}
Add Media Player and resume to last playback position
In order to ease podcast listening, the player can be put on top of the feed entry as main content.
Use the `Use podcast player` option to enable that. It works on audio and video.
Also, when playing audio or video, progression will be saved in order to be able to resume listening later.
This position saving is done using the original attachement/enclosures player AND podcast player and do not rely on
the podcast player option ti be enabled.
Additionally, I made the player fill the width with the entry container to ease seeking and have a bigger video.
updateEnclosures now keep existing enclosures based on URL
When feeds get updated, enclosures entries are always wiped and re-created. This cause two issue
- enclosure progression get lost in the process
- enclosure ID changes
I used the URL as identifier of an enclosure. Not perfect but hopefully should work.
When an enclosure already exist, I simply do nothing and leave the entry as is in the database.
If anyone is listening/watching to this enclosure during the refresh, the id stay coherent and progression saving still works.
The updateEnclosures function got a bit more complex. I tried to make it the more clear I could.
Some optimisation are possible but would make the function harder to read in my opinion.
I'm not sure if this is often the case, but some feeds may include tracking or simply change the url each
time we update the feed. In those situation, enclosures ids and progression will be lost.
I have no idea how to handle this last situation. Use the size instead/alongside url to define the identity of an enclosure ?
Translation: english as placeholder for every language except French
Aside, I tested a video feed and fixed a few things for it. In fact, the MimeType was not working
at all on my side, and found a pretty old stackoverflow discussion that suggest to use an Apple non-standard MimeType for
m4v video format. I only did one substitution because I only have one feed to test. Any new video feed can make this go away
or evolve depending on the situation. Real video feeds does not tend to be easy to find and test extensively this.
Co-authored-by: toastal
2023-04-13 11:46:43 +02:00
const currentPositionInSeconds = Math . floor ( playerElement . currentTime ) ; // we do not need a precise value
const lastKnownPositionInSeconds = parseInt ( playerElement . dataset . lastPosition , 10 ) ;
2024-07-28 21:29:45 +02:00
const markAsReadOnCompletion = parseFloat ( playerElement . dataset . markReadOnCompletion ) ; //completion percentage to mark as read
Add Media Player and resume to last playback position
In order to ease podcast listening, the player can be put on top of the feed entry as main content.
Use the `Use podcast player` option to enable that. It works on audio and video.
Also, when playing audio or video, progression will be saved in order to be able to resume listening later.
This position saving is done using the original attachement/enclosures player AND podcast player and do not rely on
the podcast player option ti be enabled.
Additionally, I made the player fill the width with the entry container to ease seeking and have a bigger video.
updateEnclosures now keep existing enclosures based on URL
When feeds get updated, enclosures entries are always wiped and re-created. This cause two issue
- enclosure progression get lost in the process
- enclosure ID changes
I used the URL as identifier of an enclosure. Not perfect but hopefully should work.
When an enclosure already exist, I simply do nothing and leave the entry as is in the database.
If anyone is listening/watching to this enclosure during the refresh, the id stay coherent and progression saving still works.
The updateEnclosures function got a bit more complex. I tried to make it the more clear I could.
Some optimisation are possible but would make the function harder to read in my opinion.
I'm not sure if this is often the case, but some feeds may include tracking or simply change the url each
time we update the feed. In those situation, enclosures ids and progression will be lost.
I have no idea how to handle this last situation. Use the size instead/alongside url to define the identity of an enclosure ?
Translation: english as placeholder for every language except French
Aside, I tested a video feed and fixed a few things for it. In fact, the MimeType was not working
at all on my side, and found a pretty old stackoverflow discussion that suggest to use an Apple non-standard MimeType for
m4v video format. I only did one substitution because I only have one feed to test. Any new video feed can make this go away
or evolve depending on the situation. Real video feeds does not tend to be easy to find and test extensively this.
Co-authored-by: toastal
2023-04-13 11:46:43 +02:00
const recordInterval = 10 ;
// we limit the number of update to only one by interval. Otherwise, we would have multiple update per seconds
if ( currentPositionInSeconds >= ( lastKnownPositionInSeconds + recordInterval ) ||
currentPositionInSeconds <= ( lastKnownPositionInSeconds - recordInterval )
) {
playerElement . dataset . lastPosition = currentPositionInSeconds . toString ( ) ;
2024-03-11 17:23:20 +01:00
const request = new RequestBuilder ( playerElement . dataset . saveUrl ) ;
2024-03-10 20:52:56 -07:00
request . withBody ( { progression : currentPositionInSeconds } ) ;
Add Media Player and resume to last playback position
In order to ease podcast listening, the player can be put on top of the feed entry as main content.
Use the `Use podcast player` option to enable that. It works on audio and video.
Also, when playing audio or video, progression will be saved in order to be able to resume listening later.
This position saving is done using the original attachement/enclosures player AND podcast player and do not rely on
the podcast player option ti be enabled.
Additionally, I made the player fill the width with the entry container to ease seeking and have a bigger video.
updateEnclosures now keep existing enclosures based on URL
When feeds get updated, enclosures entries are always wiped and re-created. This cause two issue
- enclosure progression get lost in the process
- enclosure ID changes
I used the URL as identifier of an enclosure. Not perfect but hopefully should work.
When an enclosure already exist, I simply do nothing and leave the entry as is in the database.
If anyone is listening/watching to this enclosure during the refresh, the id stay coherent and progression saving still works.
The updateEnclosures function got a bit more complex. I tried to make it the more clear I could.
Some optimisation are possible but would make the function harder to read in my opinion.
I'm not sure if this is often the case, but some feeds may include tracking or simply change the url each
time we update the feed. In those situation, enclosures ids and progression will be lost.
I have no idea how to handle this last situation. Use the size instead/alongside url to define the identity of an enclosure ?
Translation: english as placeholder for every language except French
Aside, I tested a video feed and fixed a few things for it. In fact, the MimeType was not working
at all on my side, and found a pretty old stackoverflow discussion that suggest to use an Apple non-standard MimeType for
m4v video format. I only did one substitution because I only have one feed to test. Any new video feed can make this go away
or evolve depending on the situation. Real video feeds does not tend to be easy to find and test extensively this.
Co-authored-by: toastal
2023-04-13 11:46:43 +02:00
request . execute ( ) ;
2024-07-28 21:29:45 +02:00
// Handle the mark as read on completion
if ( markAsReadOnCompletion >= 0 && playerElement . duration > 0 ) {
const completion = currentPositionInSeconds / playerElement . duration ;
if ( completion >= markAsReadOnCompletion ) {
handleEntryStatus ( "none" , document . querySelector ( ":is(a, button)[data-toggle-status]" ) , true ) ;
}
}
Add Media Player and resume to last playback position
In order to ease podcast listening, the player can be put on top of the feed entry as main content.
Use the `Use podcast player` option to enable that. It works on audio and video.
Also, when playing audio or video, progression will be saved in order to be able to resume listening later.
This position saving is done using the original attachement/enclosures player AND podcast player and do not rely on
the podcast player option ti be enabled.
Additionally, I made the player fill the width with the entry container to ease seeking and have a bigger video.
updateEnclosures now keep existing enclosures based on URL
When feeds get updated, enclosures entries are always wiped and re-created. This cause two issue
- enclosure progression get lost in the process
- enclosure ID changes
I used the URL as identifier of an enclosure. Not perfect but hopefully should work.
When an enclosure already exist, I simply do nothing and leave the entry as is in the database.
If anyone is listening/watching to this enclosure during the refresh, the id stay coherent and progression saving still works.
The updateEnclosures function got a bit more complex. I tried to make it the more clear I could.
Some optimisation are possible but would make the function harder to read in my opinion.
I'm not sure if this is often the case, but some feeds may include tracking or simply change the url each
time we update the feed. In those situation, enclosures ids and progression will be lost.
I have no idea how to handle this last situation. Use the size instead/alongside url to define the identity of an enclosure ?
Translation: english as placeholder for every language except French
Aside, I tested a video feed and fixed a few things for it. In fact, the MimeType was not working
at all on my side, and found a pretty old stackoverflow discussion that suggest to use an Apple non-standard MimeType for
m4v video format. I only did one substitution because I only have one feed to test. Any new video feed can make this go away
or evolve depending on the situation. Real video feeds does not tend to be easy to find and test extensively this.
Co-authored-by: toastal
2023-04-13 11:46:43 +02:00
}
}
2023-08-01 06:11:39 +02:00
2024-07-28 21:29:45 +02:00
/ * *
* Check if the player is actually playing a media
* @ param element the player element itself
* @ returns { boolean }
* /
function isPlayerPlaying ( element ) {
return element &&
element . currentTime > 0 &&
! element . paused &&
! element . ended &&
element . readyState > 2 ; //https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/readyState
}
2024-04-10 17:14:38 +02:00
/ * *
* Handle all clicks on media player controls button on enclosures .
* Will change the current speed and position of the player accordingly .
* Will not save anything , all is done client - side , however , changing the position
* will trigger the handlePlayerProgressionSave and save the new position backends side .
* @ param { Element } button
* /
function handleMediaControl ( button ) {
const action = button . dataset . enclosureAction ;
const value = parseFloat ( button . dataset . actionValue ) ;
const targetEnclosureId = button . dataset . enclosureId ;
const enclosures = document . querySelectorAll ( ` audio[data-enclosure-id=" ${ targetEnclosureId } "],video[data-enclosure-id=" ${ targetEnclosureId } "] ` ) ;
const speedIndicator = document . querySelectorAll ( ` span.speed-indicator[data-enclosure-id=" ${ targetEnclosureId } "] ` ) ;
enclosures . forEach ( ( enclosure ) => {
switch ( action ) {
case "seek" :
2025-01-16 13:39:06 +01:00
enclosure . currentTime = Math . max ( enclosure . currentTime + value , 0 ) ;
2024-04-10 17:14:38 +02:00
break ;
case "speed" :
// I set a floor speed of 0.25 to avoid too slow speed where it gives the impression it stopped.
// 0.25 was chosen because it will allow to get back to 1x in two "faster" click, and lower value with same property would be 0.
enclosure . playbackRate = Math . max ( 0.25 , enclosure . playbackRate + value ) ;
speedIndicator . forEach ( ( speedI ) => {
// Two digit precision to ensure we always have the same number of characters (4) to avoid controls moving when clicking buttons because of more or less characters.
// The trick only work on rate less than 10, but it feels an acceptable tread of considering the feature
speedI . innerText = ` ${ enclosure . playbackRate . toFixed ( 2 ) } x ` ;
} ) ;
break ;
case "speed-reset" :
enclosure . playbackRate = value ;
speedIndicator . forEach ( ( speedI ) => {
// Two digit precision to ensure we always have the same number of characters (4) to avoid controls moving when clicking buttons because of more or less characters.
// The trick only work on rate less than 10, but it feels an acceptable tread of considering the feature
speedI . innerText = ` ${ enclosure . playbackRate . toFixed ( 2 ) } x ` ;
} ) ;
break ;
}
} ) ;
}