Jellyfin Forum
Jellyfin UI Enhancements - Printable Version

+- Jellyfin Forum (https://forum.jellyfin.org)
+-- Forum: Development (https://forum.jellyfin.org/f-development)
+--- Forum: Web Development (https://forum.jellyfin.org/f-web-development)
+--- Thread: Jellyfin UI Enhancements (/t-jellyfin-ui-enhancements)



Jellyfin UI Enhancements - SethBacon - 2024-10-28

As we approach another Thanksgiving, its important to remember that Jellyfin Server and Jellyfin-Web are essentially your divorced parents who, despite living together for years, will cooperate to a bare minimum to serve you video or turkey. Dads treating Mom like any other client now, and they're gonna be making a whoooole deal about it. Like You have to ask Dad if Mom could use his plug-in charger, & he'll be all "I serve your mom alimony and user sessions all day long she cannot use MY plug-ins". And then it starts to get a little awkward, cause dad bought himself a new porsche webhook, while mom's still rocking her tired-ass pre-covid UI theme-options with a smile. But come on; could we show a little more love to the thing our users Actually Have To Look at everyday and make this a little easier on everyone? God damn, it always does fall on the children of divorce. Anyways... This is gonna be kinda dense.

Thanks to the prodigious bobhasnosoul's illuminations last year, I've been tinkering with this JFWeb injection stuff more seriously since JFforAndroidTV pushed a downgrade (throwing out LibVLC support*), and I thought it might be valuable to document the quirks and features Ive leveraged for the benefit of my users, for others' projects around here. Turning our standard jellyfin-web experience into one with blackjack and hookers;



Again, its important to note these are not standard jellyfin plugins, and they do not modify the server - we are inserting hooks for scripts or pages which allow us to change Jellyfin-web UI, without recompiling both as a custom distro. But by modifying the web UI, We can edit any page (login, home, details, libraries, player ect.) to insert or hide, force themes or elements. We can call outside api's (Youtube frames, weather plugins, further media info) and insert them onto said pages. We can edit media items metadata through api calls (instigated through user behaviour in the injected UI elements) and those changes can be viewed by any user on web/desktop/jmp/android (tv**) and probably others (anything that wraps the web client).

I suspect theres lots of UI improvements people would love to add, ala CSS themes, if we had some universal script access to JF web, just like with the custom CSS box? But im not holding my breath.

Now, I am gonna say it a third time: look at the forum section you're in, because these are not really plugins (which modify the jellyfin server behaviour, these modify the jellyfin-web behaviour which speaks to the server) there are some issues and limitations, and they are slightly trickier to install than a standard plugin. 

We can inject the code by placing it in a place that jellyfin-web can access, and then editing the page which jellyfin-web uses to display to display to users (so far we've found either index.html or home-html.~random chrs~.chunk.js to work. many of @bobhasnosouls mods make use of others I think, theres probably a lot of potential hook locations and I dont know best practices)

Sample JS injection line:

<script src="/web/userRequests.js"></script>

Sample html iframe with styling options injection line:

<style>.injectediframe { width: 100vw; height: 300px; display: block; margin: 20px auto 10px auto;}</style><iframe class="injectediframe" src="/web/ui/sports.html" tabindex="0"></iframe>

Ill post some example functions below.

There are also a number of tradeoffs on where an injection script is placed, vs what it may easily modify vs tracking and containing instances of itself in jellyfins SPA architecture: Jellyfin-web uses a single page app design, and navigating back to the homepage via the browsers back button / jellyfin back button vs jellyfins home button make cause script duplications or variable mismatches, if your injection is called from the chunk file.

Due to CORS limitations we have minimal options on skinning iframes, if you're bringing in outside content like for example a weather section; It would be better if you can call the remote sites API's OR APIify OR local mirror the site contents and grab what you want. These injections for similar reasons can read from but cannot write to the jellyfin web or jellyfin plugins directories. One could conceivably make a plugin which would listen for commands and make writes, But, we can use media items themselves as a sort of scratch pad if you wanted to save data in the variably sized Tags field, for example. 

Finally, this is additionally challenging for users on Linux, as the Jellyfin web folder is structured or placed differently. Bob has the answer to that in his version of Featured Content.

All that being said, I hope this helps clarify how some people are making UI changes and how that differs from regular plugins.




*I love this software: it is a huge benefit for my family and friends, some of whom are disabled or remote and its not that simple for them to sideload a custom apk I make fixing a JFforATV 'update' which causes the video playing app not to play the videos. like.. please think better about changes like this.
** In the interim while JF for android cant do random files or av1 encodes, you could also write a plugin to force tv-mode and assist in focus, allowing users on an android box to use the regular android client with a remote, playing video as one would expect. Coming soon.


RE: Jellyfin UI Enhancements - SethBacon - 2024-10-28

Some example functions. Note: for a lot of media manipulation you will want to generate an api code in jellyfin Admin settings, thenm hardcode it into your injections to make api calls. 

Code:
// Get the current authenticated user's info
const getCurrentUser = () => {
    return fetch('/Users/Me', {
        method: 'GET',
        headers: {
            'Authorization': `MediaBrowser Token="${ApiClient.accessToken()}"`,
            'Content-Type': 'application/json'
        }
    })
    .then(response => response.json())
    .catch(error => {
        console.error('Error getting current user:', error);
        return null;
    });
};

// Get a user's profile image URL by their ID
const getUserImageUrl = (userId) => {
    return `${ApiClient.serverAddress()}/Users/${userId}/Images/Primary?quality=90`;
};

// Update a media item's tags
const updateMediaTags = (itemId, newTags) => {
    return ApiClient.getItem(ApiClient.getCurrentUserId(), itemId)
        .then(item => {
            const updatedTags = [...new Set([...item.Tags, ...newTags])];
            return ApiClient.updateItem({
                Id: itemId,
                Tags: updatedTags
            });
        })
        .catch(error => {
            console.error('Error updating tags:', error);
            return false;
        });
};

// Watch for navigation changes in Jellyfin's SPA
const watchNavigation = (callback) => {
    const observer = new MutationObserver((mutations) => {
        mutations.forEach((mutation) => {
            if (mutation.type === 'childList' &&
                mutation.target.classList.contains('view')) {
                callback(window.location.hash);
            }
        });
    });
   
    observer.observe(document.querySelector('div[data-role="content"]'), {
        childList: true,
        subtree: true
    });
   
    return observer;
};

// Inject a custom element into a specific page section
const injectElement = (targetSelector, htmlContent, position = 'beforeend') => {
    const target = document.querySelector(targetSelector);
    if (target) {
        target.insertAdjacentHTML(position, htmlContent);
        return true;
    }
    return false;
};

// Get all users and their online status
const getOnlineUsers = () => {
    return fetch('/Users/Query', {
        method: 'GET',
        headers: {
            'Authorization': `MediaBrowser Token="${ApiClient.accessToken()}"`,
            'Content-Type': 'application/json'
        }
    })
    .then(response => response.json())
    .then(users => {
        return ApiClient.getSessions()
            .then(sessions => {
                return users.map(user => ({
                    ...user,
                    isOnline: sessions.some(session => session.UserId === user.Id)
                }));
            });
    })
    .catch(error => {
        console.error('Error getting online users:', error);
        return [];
    });
};

// Add a custom button to the media details page
const addCustomButton = (text, onClick) => {
    const buttonHtml = `
        <button is="emby-button"
                type="button"
                class="button-flat btnPlay button-flat-custom raised">
            <span>${text}</span>
        </button>
    `;
   
    injectElement('.detailButtons', buttonHtml);
   
    const button = document.querySelector('.button-flat-custom');
    if (button) {
        button.addEventListener('click', onClick);
    }
};

// Watch for theme changes and apply custom styles
const watchThemeChanges = (callback) => {
    const observer = new MutationObserver((mutations) => {
        mutations.forEach((mutation) => {
            if (mutation.attributeName === 'data-theme') {
                const theme = document.documentElement.getAttribute('data-theme');
                callback(theme);
            }
        });
    });
   
    observer.observe(document.documentElement, {
        attributes: true,
        attributeFilter: ['data-theme']
    });
   
    return observer;
};
// Example usage of the functions:
/*
// Watch for navigation changes
const navObserver = watchNavigation((newPath) => {
    console.log('Navigation changed to:', newPath);
    if (newPath.includes('details')) {
        addCustomButton('My Custom Action', () => {
            console.log('Custom button clicked!');
        });
    }
});

// Watch for theme changes
const themeObserver = watchThemeChanges((newTheme) => {
    console.log('Theme changed to:', newTheme);
    // Apply custom styles based on theme
});

// Get current user and update UI
async function updateUserInfo() {
    const user = await getCurrentUser();
    if (user) {
        const userImage = getUserImageUrl(user.Id);
        console.log('Current user:', user.Name);
        console.log('User image:', userImage);
    }
}

// Update media tags
async function addTagToMedia(itemId, newTag) {
    const success = await updateMediaTags(itemId, [newTag]);
    if (success) {
        console.log('Tags updated successfully');
    }
}

// Check online users
async function showOnlineUsers() {
    const users = await getOnlineUsers();
    users.forEach(user => {
        console.log(`${user.Name} is ${user.isOnline ? 'online' : 'offline'}`);
    });
}
*/



RE: Jellyfin UI Enhancements - Ted Hinklater - 2024-10-29

UI's looking great man and ReelFriends especially is fantastic there, is 10.10.0 playing nice with it?


RE: Jellyfin UI Enhancements - M0RPH3US - 2024-10-29

(2024-10-28, 08:02 PM)SethBacon Wrote: Some example functions. Note: for a lot of media manipulation you will want to generate an api code in jellyfin Admin settings, thenm hardcode it into your injections to make api calls. 

Code:
// Get the current authenticated user's info
const getCurrentUser = () => {
    return fetch('/Users/Me', {
        method: 'GET',
        headers: {
            'Authorization': `MediaBrowser Token="${ApiClient.accessToken()}"`,
            'Content-Type': 'application/json'
        }
    })
    .then(response => response.json())
    .catch(error => {
        console.error('Error getting current user:', error);
        return null;
    });
};

// Get a user's profile image URL by their ID
const getUserImageUrl = (userId) => {
    return `${ApiClient.serverAddress()}/Users/${userId}/Images/Primary?quality=90`;
};

// Update a media item's tags
const updateMediaTags = (itemId, newTags) => {
    return ApiClient.getItem(ApiClient.getCurrentUserId(), itemId)
        .then(item => {
            const updatedTags = [...new Set([...item.Tags, ...newTags])];
            return ApiClient.updateItem({
                Id: itemId,
                Tags: updatedTags
            });
        })
        .catch(error => {
            console.error('Error updating tags:', error);
            return false;
        });
};

// Watch for navigation changes in Jellyfin's SPA
const watchNavigation = (callback) => {
    const observer = new MutationObserver((mutations) => {
        mutations.forEach((mutation) => {
            if (mutation.type === 'childList' &&
                mutation.target.classList.contains('view')) {
                callback(window.location.hash);
            }
        });
    });
   
    observer.observe(document.querySelector('div[data-role="content"]'), {
        childList: true,
        subtree: true
    });
   
    return observer;
};

// Inject a custom element into a specific page section
const injectElement = (targetSelector, htmlContent, position = 'beforeend') => {
    const target = document.querySelector(targetSelector);
    if (target) {
        target.insertAdjacentHTML(position, htmlContent);
        return true;
    }
    return false;
};

// Get all users and their online status
const getOnlineUsers = () => {
    return fetch('/Users/Query', {
        method: 'GET',
        headers: {
            'Authorization': `MediaBrowser Token="${ApiClient.accessToken()}"`,
            'Content-Type': 'application/json'
        }
    })
    .then(response => response.json())
    .then(users => {
        return ApiClient.getSessions()
            .then(sessions => {
                return users.map(user => ({
                    ...user,
                    isOnline: sessions.some(session => session.UserId === user.Id)
                }));
            });
    })
    .catch(error => {
        console.error('Error getting online users:', error);
        return [];
    });
};

// Add a custom button to the media details page
const addCustomButton = (text, onClick) => {
    const buttonHtml = `
        <button is="emby-button"
                type="button"
                class="button-flat btnPlay button-flat-custom raised">
            <span>${text}</span>
        </button>
    `;
   
    injectElement('.detailButtons', buttonHtml);
   
    const button = document.querySelector('.button-flat-custom');
    if (button) {
        button.addEventListener('click', onClick);
    }
};

// Watch for theme changes and apply custom styles
const watchThemeChanges = (callback) => {
    const observer = new MutationObserver((mutations) => {
        mutations.forEach((mutation) => {
            if (mutation.attributeName === 'data-theme') {
                const theme = document.documentElement.getAttribute('data-theme');
                callback(theme);
            }
        });
    });
   
    observer.observe(document.documentElement, {
        attributes: true,
        attributeFilter: ['data-theme']
    });
   
    return observer;
};
// Example usage of the functions:
/*
// Watch for navigation changes
const navObserver = watchNavigation((newPath) => {
    console.log('Navigation changed to:', newPath);
    if (newPath.includes('details')) {
        addCustomButton('My Custom Action', () => {
            console.log('Custom button clicked!');
        });
    }
});

// Watch for theme changes
const themeObserver = watchThemeChanges((newTheme) => {
    console.log('Theme changed to:', newTheme);
    // Apply custom styles based on theme
});

// Get current user and update UI
async function updateUserInfo() {
    const user = await getCurrentUser();
    if (user) {
        const userImage = getUserImageUrl(user.Id);
        console.log('Current user:', user.Name);
        console.log('User image:', userImage);
    }
}

// Update media tags
async function addTagToMedia(itemId, newTag) {
    const success = await updateMediaTags(itemId, [newTag]);
    if (success) {
        console.log('Tags updated successfully');
    }
}

// Check online users
async function showOnlineUsers() {
    const users = await getOnlineUsers();
    users.forEach(user => {
        console.log(`${user.Name} is ${user.isOnline ? 'online' : 'offline'}`);
    });
}
*/

Oh that looks sick. I have a custom varient of my bar which yet I didnot release as public (it's there on Bob's repo as the makd version, I have some local tweaks which I will do a public release once I get my head around), but to tackle the SPA issue, we can kind of prevent the default behaviour for all buttons (header bar home, hamburger menu home, and the jellylogo at the top of the dashboard) to route it to 
Code:
window.location.href = '/web/index.html#/home.html
then we can circumnavigate that issue. I am caught up with office, but can catch up with you guys to share what I have.

But @SethBacon and @Ted Hinklater for sure, look forward really to Collab for the bar again


RE: Jellyfin UI Enhancements - TheDreadPirate - 2024-10-29

@M0RPH3US - If a user has a space in their user name, put their name in quotes after the @ and it will properly tag them. I edited your post for you. :-)