init shuffle season, add button

not working
This commit is contained in:
Pas 2026-02-20 20:07:30 -07:00
parent 752e8cadb1
commit 77e20ec349
7 changed files with 158 additions and 45 deletions

View file

@ -86,6 +86,7 @@ export enum Icons {
TRANSLATE = "translate", TRANSLATE = "translate",
THUMBS_UP = "thumbsUp", THUMBS_UP = "thumbsUp",
THUMBS_DOWN = "thumbsDown", THUMBS_DOWN = "thumbsDown",
SHUFFLE = "shuffle",
} }
export interface IconProps { export interface IconProps {
@ -189,6 +190,7 @@ const iconList: Record<Icons, string> = {
translate: `<svg width="1em" height="1em" fill="currentColor" viewBox="0 0 52 52" data-name="Layer 1" id="Layer_1" xmlns="http://www.w3.org/2000/svg"><path d="M39,18.67H35.42l-4.2,11.12A29,29,0,0,1,20.6,24.91a28.76,28.76,0,0,0,7.11-14.49h5.21a2,2,0,0,0,0-4H19.67V2a2,2,0,1,0-4,0V6.42H2.41a2,2,0,0,0,0,4H7.63a28.73,28.73,0,0,0,7.1,14.49A29.51,29.51,0,0,1,3.27,30a2,2,0,0,0,.43,4,1.61,1.61,0,0,0,.44-.05,32.56,32.56,0,0,0,13.53-6.25,32,32,0,0,0,12.13,5.9L22.83,52H28l2.7-7.76H43.64L46.37,52h5.22Zm-15.3-8.25a23.76,23.76,0,0,1-6,11.86,23.71,23.71,0,0,1-6-11.86Zm8.68,29.15,4.83-13.83L42,39.57Z"/></svg>`, translate: `<svg width="1em" height="1em" fill="currentColor" viewBox="0 0 52 52" data-name="Layer 1" id="Layer_1" xmlns="http://www.w3.org/2000/svg"><path d="M39,18.67H35.42l-4.2,11.12A29,29,0,0,1,20.6,24.91a28.76,28.76,0,0,0,7.11-14.49h5.21a2,2,0,0,0,0-4H19.67V2a2,2,0,1,0-4,0V6.42H2.41a2,2,0,0,0,0,4H7.63a28.73,28.73,0,0,0,7.1,14.49A29.51,29.51,0,0,1,3.27,30a2,2,0,0,0,.43,4,1.61,1.61,0,0,0,.44-.05,32.56,32.56,0,0,0,13.53-6.25,32,32,0,0,0,12.13,5.9L22.83,52H28l2.7-7.76H43.64L46.37,52h5.22Zm-15.3-8.25a23.76,23.76,0,0,1-6,11.86,23.71,23.71,0,0,1-6-11.86Zm8.68,29.15,4.83-13.83L42,39.57Z"/></svg>`,
thumbsUp: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 640 640"><!--!Font Awesome Free v7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M144 224C161.7 224 176 238.3 176 256L176 512C176 529.7 161.7 544 144 544L96 544C78.3 544 64 529.7 64 512L64 256C64 238.3 78.3 224 96 224L144 224zM334.6 80C361.9 80 384 102.1 384 129.4L384 133.6C384 140.4 382.7 147.2 380.2 153.5L352 224L512 224C538.5 224 560 245.5 560 272C560 291.7 548.1 308.6 531.1 316C548.1 323.4 560 340.3 560 360C560 383.4 543.2 402.9 521 407.1C525.4 414.4 528 422.9 528 432C528 454.2 513 472.8 492.6 478.3C494.8 483.8 496 489.8 496 496C496 522.5 474.5 544 448 544L360.1 544C323.8 544 288.5 531.6 260.2 508.9L248 499.2C232.8 487.1 224 468.7 224 449.2L224 262.6C224 247.7 227.5 233 234.1 219.7L290.3 107.3C298.7 90.6 315.8 80 334.6 80z"/></svg>`, thumbsUp: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 640 640"><!--!Font Awesome Free v7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M144 224C161.7 224 176 238.3 176 256L176 512C176 529.7 161.7 544 144 544L96 544C78.3 544 64 529.7 64 512L64 256C64 238.3 78.3 224 96 224L144 224zM334.6 80C361.9 80 384 102.1 384 129.4L384 133.6C384 140.4 382.7 147.2 380.2 153.5L352 224L512 224C538.5 224 560 245.5 560 272C560 291.7 548.1 308.6 531.1 316C548.1 323.4 560 340.3 560 360C560 383.4 543.2 402.9 521 407.1C525.4 414.4 528 422.9 528 432C528 454.2 513 472.8 492.6 478.3C494.8 483.8 496 489.8 496 496C496 522.5 474.5 544 448 544L360.1 544C323.8 544 288.5 531.6 260.2 508.9L248 499.2C232.8 487.1 224 468.7 224 449.2L224 262.6C224 247.7 227.5 233 234.1 219.7L290.3 107.3C298.7 90.6 315.8 80 334.6 80z"/></svg>`,
thumbsDown: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 640 640"><!--!Font Awesome Free v7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M448 96C474.5 96 496 117.5 496 144C496 150.3 494.7 156.2 492.6 161.7C513 167.2 528 185.8 528 208C528 217.1 525.4 225.6 521 232.9C543.2 237.1 560 256.6 560 280C560 299.7 548.1 316.6 531.1 324C548.1 331.4 560 348.3 560 368C560 394.5 538.5 416 512 416L352 416L380.2 486.4C382.7 492.7 384 499.5 384 506.3L384 510.5C384 537.8 361.9 559.9 334.6 559.9C315.9 559.9 298.8 549.3 290.4 532.6L234.1 420.3C227.4 407 224 392.3 224 377.4L224 190.8C224 171.4 232.9 153 248 140.8L260.2 131.1C288.6 108.4 323.8 96 360.1 96L448 96zM144 160C161.7 160 176 174.3 176 192L176 448C176 465.7 161.7 480 144 480L96 480C78.3 480 64 465.7 64 448L64 192C64 174.3 78.3 160 96 160L144 160z"/></svg>`, thumbsDown: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 640 640"><!--!Font Awesome Free v7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M448 96C474.5 96 496 117.5 496 144C496 150.3 494.7 156.2 492.6 161.7C513 167.2 528 185.8 528 208C528 217.1 525.4 225.6 521 232.9C543.2 237.1 560 256.6 560 280C560 299.7 548.1 316.6 531.1 324C548.1 331.4 560 348.3 560 368C560 394.5 538.5 416 512 416L352 416L380.2 486.4C382.7 492.7 384 499.5 384 506.3L384 510.5C384 537.8 361.9 559.9 334.6 559.9C315.9 559.9 298.8 549.3 290.4 532.6L234.1 420.3C227.4 407 224 392.3 224 377.4L224 190.8C224 171.4 232.9 153 248 140.8L260.2 131.1C288.6 108.4 323.8 96 360.1 96L448 96zM144 160C161.7 160 176 174.3 176 192L176 448C176 465.7 161.7 480 144 480L96 480C78.3 480 64 465.7 64 448L64 192C64 174.3 78.3 160 96 160L144 160z"/></svg>`,
shuffle: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-shuffle"><polyline points="16 3 21 3 21 8"></polyline><line x1="4" y1="20" x2="21" y2="3"></line><polyline points="21 16 21 21 16 21"></polyline><line x1="15" y1="15" x2="21" y2="21"></line><line x1="4" y1="4" x2="9" y2="9"></line></svg>`,
}; };
export const Icon = memo((props: IconProps) => { export const Icon = memo((props: IconProps) => {

View file

@ -3,10 +3,12 @@ import { t } from "i18next";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { MWMediaType } from "@/backend/metadata/types/mw";
import { Button } from "@/components/buttons/Button"; import { Button } from "@/components/buttons/Button";
import { Dropdown } from "@/components/form/Dropdown"; import { Dropdown } from "@/components/form/Dropdown";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
import { Modal, ModalCard, useModal } from "@/components/overlays/Modal"; import { Modal, ModalCard, useModal } from "@/components/overlays/Modal";
import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
import { hasAired } from "@/components/player/utils/aired"; import { hasAired } from "@/components/player/utils/aired";
import { useBookmarkStore } from "@/stores/bookmarks"; import { useBookmarkStore } from "@/stores/bookmarks";
import { getProgressPercentage, useProgressStore } from "@/stores/progress"; import { getProgressPercentage, useProgressStore } from "@/stores/progress";
@ -47,6 +49,7 @@ export function EpisodeCarousel({
}>({}); }>({});
const updateItem = useProgressStore((s) => s.updateItem); const updateItem = useProgressStore((s) => s.updateItem);
const confirmModal = useModal("season-watch-confirm"); const confirmModal = useModal("season-watch-confirm");
const { setPlayerMeta, setDirectMeta } = usePlayerMeta();
const handleScroll = (direction: "left" | "right") => { const handleScroll = (direction: "left" | "right") => {
if (!carouselRef.current) return; if (!carouselRef.current) return;
@ -239,6 +242,102 @@ export function EpisodeCarousel({
confirmModal.show(); confirmModal.show();
}; };
const handleShuffle = () => {
console.log("handleShuffle called", {
mediaId,
selectedSeason,
showFavorites,
});
if (!mediaId) {
console.log("No mediaId, returning early");
return;
}
const episodesToShuffle = showFavorites
? favoriteEpisodes
: episodes.filter((ep) => ep.season_number === selectedSeason);
console.log("Episodes to shuffle:", episodesToShuffle.length);
if (episodesToShuffle.length === 0) {
console.log("No episodes to shuffle, returning early");
return;
}
// Fisher-Yates shuffle
const shuffled = [...episodesToShuffle];
for (let i = shuffled.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
const firstEp = shuffled[0];
console.log("First shuffled episode:", firstEp);
// Find the season for the first episode
const seasonData = seasons.find(
(s) => s.season_number === firstEp.season_number,
);
if (!seasonData) {
console.log("No seasonData found, returning early");
return;
}
console.log("Season data:", seasonData);
// Create meta with shuffled episodes
const meta = setPlayerMeta(
{
meta: {
id: mediaId.toString(),
type: MWMediaType.SERIES,
title: mediaTitle || "",
poster: mediaPosterUrl,
year: new Date().getFullYear().toString(),
seasons: seasons.map((s) => ({
id: s.id.toString(),
number: s.season_number,
title: s.name,
})),
seasonData: {
id: seasonData.id.toString(),
number: firstEp.season_number,
title: "",
episodes: shuffled.map((ep) => ({
id: ep.id.toString(),
number: ep.episode_number,
title: ep.name,
air_date: ep.air_date,
still_path: ep.still_path,
overview: ep.overview,
})),
},
},
tmdbId: mediaId.toString(),
},
firstEp.id.toString(),
);
console.log("Meta created:", meta);
console.log(
"Episodes in meta:",
meta?.episodes?.map((e) => `${e.number}: ${e.title}`),
);
if (meta) {
// Create a copy of meta with shuffled property
const metaWithShuffled = { ...meta, shuffled: true };
setDirectMeta(metaWithShuffled);
// Navigate to the first shuffled episode
const url = `/media/tmdb-tv-${mediaId}-${(mediaTitle || "").toLowerCase().replace(/[^a-z0-9]+/g, "-")}/${seasonData.id}/${firstEp.id}`;
console.log("Navigating to:", url);
window.location.href = url;
} else {
console.log("Meta creation failed");
}
};
const handleCancel = () => { const handleCancel = () => {
confirmModal.hide(); confirmModal.hide();
}; };
@ -505,6 +604,14 @@ export function EpisodeCarousel({
{/* Season Watched Confirmation */} {/* Season Watched Confirmation */}
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<button
type="button"
onClick={handleShuffle}
className="p-1.5 bg-dropdown-background hover:bg-dropdown-hoverBackground transition-colors rounded-full"
title={t("player.menus.episodes.shuffle")}
>
<Icon icon={Icons.SHUFFLE} className="h-5 w-5 text-white" />
</button>
<Modal id={confirmModal.id}> <Modal id={confirmModal.id}>
<ModalCard> <ModalCard>
<h3 className="text-lg font-semibold text-white mb-4"> <h3 className="text-lg font-semibold text-white mb-4">

View file

@ -120,10 +120,12 @@ export function NextEpisodeButton(props: {
const updateItem = useProgressStore((s) => s.updateItem); const updateItem = useProgressStore((s) => s.updateItem);
const sourceId = usePlayerStore((s) => s.sourceId); const sourceId = usePlayerStore((s) => s.sourceId);
const currentEpIndex =
meta?.episodes?.findIndex((v) => v.tmdbId === meta.episode?.tmdbId) ?? -1;
const isLastEpisode = const isLastEpisode =
!meta?.episode?.number || !meta?.episodes?.at(-1)?.number currentEpIndex === -1
? false ? false
: meta.episode.number === meta.episodes.at(-1)!.number; : currentEpIndex === (meta?.episodes?.length ?? 0) - 1;
const seasons = useSeasons(meta?.tmdbId, isLastEpisode); const seasons = useSeasons(meta?.tmdbId, isLastEpisode);
@ -148,13 +150,20 @@ export function NextEpisodeButton(props: {
const nextEp = isLastEpisode const nextEp = isLastEpisode
? nextSeasonEpisode.value ? nextSeasonEpisode.value
: meta?.episodes?.find( : meta?.episodes?.[currentEpIndex + 1];
(v) => v.number === (meta?.episode?.number ?? 0) + 1,
);
const loadNextEpisode = useCallback(() => { const loadNextEpisode = useCallback(() => {
if (!meta || !nextEp) return; if (!meta || !nextEp) return;
console.log("loadNextEpisode called");
console.log("meta.shuffled:", meta.shuffled);
console.log(
"Current episodes order:",
meta.episodes?.map((e) => `${e.number}: ${e.title}`),
);
console.log("Current episode:", meta.episode?.number, meta.episode?.title);
console.log("Next episode:", nextEp?.number, nextEp?.title);
// Store the current source as the last successful source // Store the current source as the last successful source
if (sourceId) { if (sourceId) {
setLastSuccessfulSource(sourceId); setLastSuccessfulSource(sourceId);

View file

@ -92,15 +92,28 @@ export function KeyboardEvents() {
const navigateToNextEpisode = useCallback(async () => { const navigateToNextEpisode = useCallback(async () => {
if (!meta || meta.type !== "show" || !meta.episode) return; if (!meta || meta.type !== "show" || !meta.episode) return;
console.log("navigateToNextEpisode called");
console.log("meta.shuffled:", meta.shuffled);
console.log(
"Current episodes order:",
meta.episodes?.map((e) => `${e.number}: ${e.title}`),
);
// Check if we're at the last episode of the current season // Check if we're at the last episode of the current season
const isLastEpisode = const currentEpIndex =
meta.episode.number === meta.episodes?.[meta.episodes.length - 1]?.number; meta.episodes?.findIndex((v) => v.tmdbId === meta.episode?.tmdbId) ?? -1;
const isLastEpisode = currentEpIndex === (meta.episodes?.length ?? 0) - 1;
console.log("Current episode:", meta.episode?.number, meta.episode?.title);
console.log("Current ep index:", currentEpIndex);
console.log("Is last episode:", isLastEpisode);
if (!isLastEpisode) { if (!isLastEpisode) {
// Navigate to next episode in current season // Navigate to next episode in current season
const nextEp = meta.episodes?.find( const nextEp = meta.episodes?.[currentEpIndex + 1];
(v) => v.number === meta.episode!.number + 1,
); console.log("Next episode:", nextEp?.number, nextEp?.title);
if (nextEp) { if (nextEp) {
if (sourceId) { if (sourceId) {
setLastSuccessfulSource(sourceId); setLastSuccessfulSource(sourceId);

View file

@ -20,9 +20,10 @@ export function MediaSession() {
const changeEpisode = useCallback( const changeEpisode = useCallback(
(change: number) => { (change: number) => {
const nextEp = meta?.episodes?.find( const currentEpIndex =
(v) => v.number === (meta?.episode?.number ?? 0) + change, meta?.episodes?.findIndex((v) => v.tmdbId === meta.episode?.tmdbId) ??
); -1;
const nextEp = meta?.episodes?.[currentEpIndex + change];
if (!meta || !nextEp) return; if (!meta || !nextEp) return;
const metaCopy = { ...meta }; const metaCopy = { ...meta };
@ -178,7 +179,11 @@ export function MediaSession() {
updatePositionState(e.seekTime); updatePositionState(e.seekTime);
}); });
if ((meta?.episode?.number ?? 1) > 1) { const currentEpIndex =
meta?.episodes?.findIndex((v) => v.tmdbId === meta.episode?.tmdbId) ?? -1;
const totalEpisodes = meta?.episodes?.length ?? 0;
if (currentEpIndex > 0) {
navigator.mediaSession.setActionHandler("previoustrack", () => navigator.mediaSession.setActionHandler("previoustrack", () =>
changeEpisode(-1), changeEpisode(-1),
); );
@ -186,9 +191,7 @@ export function MediaSession() {
navigator.mediaSession.setActionHandler("previoustrack", null); navigator.mediaSession.setActionHandler("previoustrack", null);
} }
const totalEpisodes = meta?.episodes?.length ?? 0; if (currentEpIndex >= 0 && currentEpIndex < totalEpisodes - 1) {
const currentEpisodeNumber = meta?.episode?.number ?? 0;
if (currentEpisodeNumber > 0 && currentEpisodeNumber < totalEpisodes) {
navigator.mediaSession.setActionHandler("nexttrack", () => navigator.mediaSession.setActionHandler("nexttrack", () =>
changeEpisode(1), changeEpisode(1),
); );
@ -210,6 +213,8 @@ export function MediaSession() {
meta?.type, meta?.type,
meta?.poster, meta?.poster,
meta?.season?.number, meta?.season?.number,
meta?.episode?.tmdbId,
meta?.episodes,
]); ]);
return null; return null;

View file

@ -106,41 +106,17 @@ export function BookmarkSyncer() {
syncTimeout = setTimeout(syncImmediately, 100); syncTimeout = setTimeout(syncImmediately, 100);
}; };
// Override the addBookmark function to trigger immediate sync const unsub = useBookmarkStore.subscribe((state, prevState) => {
const originalAddBookmark = useBookmarkStore.getState().addBookmark; if (state.updateQueue.length > prevState.updateQueue.length) {
useBookmarkStore.setState({
addBookmark: (...args) => {
originalAddBookmark(...args);
// Trigger debounced sync after adding bookmark
debouncedSync(); debouncedSync();
}, }
});
// Override removeBookmark to trigger immediate sync
const originalRemoveBookmark = useBookmarkStore.getState().removeBookmark;
useBookmarkStore.setState({
removeBookmark: (...args) => {
originalRemoveBookmark(...args);
// Trigger debounced sync after removing bookmark
debouncedSync();
},
});
// Override toggleFavoriteEpisode to trigger immediate sync
const originalToggleFavoriteEpisode =
useBookmarkStore.getState().toggleFavoriteEpisode;
useBookmarkStore.setState({
toggleFavoriteEpisode: (...args) => {
originalToggleFavoriteEpisode(...args);
// Trigger debounced sync after toggling favorite episode
debouncedSync();
},
}); });
return () => { return () => {
if (syncTimeout) { if (syncTimeout) {
clearTimeout(syncTimeout); clearTimeout(syncTimeout);
} }
unsub();
}; };
}, [removeUpdateItem, url]); }, [removeUpdateItem, url]);

View file

@ -47,6 +47,7 @@ export interface PlayerMeta {
tmdbId: string; tmdbId: string;
title: string; title: string;
}; };
shuffled?: boolean;
} }
export interface Caption { export interface Caption {