diff --git a/codegen/generated/handlers.json b/codegen/generated/handlers.json index 90e77e2e..708298b4 100644 --- a/codegen/generated/handlers.json +++ b/codegen/generated/handlers.json @@ -3298,6 +3298,15 @@ "typescriptType": "AL_BaseAnime", "required": false, "descriptions": [] + }, + { + "name": "ClientId", + "jsonName": "clientId", + "goType": "string", + "usedStructType": "", + "typescriptType": "string", + "required": true, + "descriptions": [] } ], "returns": "bool", @@ -8924,6 +8933,38 @@ "returnTypescriptType": "Status" } }, + { + "name": "HandleCheckForUpdates", + "trimmedName": "CheckForUpdates", + "comments": [ + "HandleCheckForUpdates", + "", + "\t@summary forces a re-check for updates and returns the result.", + "\t@desc This resets the update cache and performs a fresh check for updates.", + "\t@desc If an error occurs, it will return an empty update.", + "\t@route /api/v1/check-for-updates [POST]", + "\t@returns updater.Update", + "" + ], + "filepath": "internal/handlers/releases.go", + "filename": "releases.go", + "api": { + "summary": "forces a re-check for updates and returns the result.", + "descriptions": [ + "This resets the update cache and performs a fresh check for updates.", + "If an error occurs, it will return an empty update." + ], + "endpoint": "/api/v1/check-for-updates", + "methods": [ + "POST" + ], + "params": [], + "bodyFields": [], + "returns": "updater.Update", + "returnGoType": "updater.Update", + "returnTypescriptType": "Updater_Update" + } + }, { "name": "HandleGetLatestUpdate", "trimmedName": "GetLatestUpdate", diff --git a/codegen/generated/public_structs.json b/codegen/generated/public_structs.json index bf6ea676..eed1d33f 100644 --- a/codegen/generated/public_structs.json +++ b/codegen/generated/public_structs.json @@ -27811,6 +27811,17 @@ "public": true, "comments": [] }, + { + "name": "logoutInProgress", + "jsonName": "logoutInProgress", + "goType": "atomic.Bool", + "typescriptType": "Bool", + "usedTypescriptType": "Bool", + "usedStructName": "atomic.Bool", + "required": false, + "public": false, + "comments": [] + }, { "name": "HookManager", "jsonName": "HookManager", @@ -29497,6 +29508,17 @@ "required": true, "public": true, "comments": [] + }, + { + "name": "UpdateChannel", + "jsonName": "updateChannel", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [ + " \"github\", \"seanime\", \"seanime_nightly\"" + ] } ], "comments": [] @@ -34936,6 +34958,17 @@ " This function is called to refresh the AniList collection" ] }, + { + "name": "hmacTokenFunc", + "jsonName": "hmacTokenFunc", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " Generates HMAC token query param for stream URLs" + ] + }, { "name": "nativePlayer", "jsonName": "nativePlayer", @@ -35214,6 +35247,15 @@ "required": false, "public": true, "comments": [] + }, + { + "name": "HMACTokenFunc", + "jsonName": "HMACTokenFunc", + "goType": "", + "typescriptType": "any", + "required": true, + "public": true, + "comments": [] } ], "comments": [ @@ -70643,6 +70685,17 @@ "comments": [ " When collections were last fetched" ] + }, + { + "name": "logoutFunc", + "jsonName": "logoutFunc", + "goType": "", + "typescriptType": "any", + "required": true, + "public": false, + "comments": [ + " called when an invalid token is detected" + ] } ], "comments": [] @@ -87400,6 +87453,15 @@ "public": false, "comments": [] }, + { + "name": "UpdateChannel", + "jsonName": "UpdateChannel", + "goType": "string", + "typescriptType": "string", + "required": true, + "public": true, + "comments": [] + }, { "name": "logger", "jsonName": "logger", diff --git a/internal/directstream/subtitles.go b/internal/directstream/subtitles.go index 47c7facf..74600b0e 100644 --- a/internal/directstream/subtitles.go +++ b/internal/directstream/subtitles.go @@ -8,6 +8,7 @@ import ( "io" "seanime/internal/events" "seanime/internal/mkvparser" + "seanime/internal/nativeplayer" "seanime/internal/util" "strings" "sync" @@ -144,28 +145,64 @@ func (s *BaseStream) StartSubtitleStreamP(stream Stream, playbackCtx context.Con subtitleChannelActive := true errorChannelActive := true + // Throttle local file streams more aggressively since disk i/o is unbounded. + // Network-based streams (torrent, debrid, nakama) are naturally throttled by bandwidth. + isLocalFile := stream.Type() == nativeplayer.StreamTypeFile + batchSleepDuration := 200 * time.Millisecond + flushInterval := 100 * time.Millisecond + maxBatchSize := 500 + if isLocalFile { + batchSleepDuration = 500 * time.Millisecond + flushInterval = 300 * time.Millisecond + maxBatchSize = 50 + } + + eventBatch := make([]*mkvparser.SubtitleEvent, 0, maxBatchSize) + flushBatch := func() { + if len(eventBatch) == 0 { + return + } + s.manager.nativePlayer.SubtitleEvents(stream.ClientId(), eventBatch) + lastSubtitleEventRWMutex.Lock() + lastSubtitleEvent = eventBatch[len(eventBatch)-1] + lastSubtitleEventRWMutex.Unlock() + + eventBatch = eventBatch[:0] + + // sleep between batches to prevent flooding + time.Sleep(batchSleepDuration) + } + + ticker := time.NewTicker(flushInterval) + defer ticker.Stop() + for subtitleChannelActive || errorChannelActive { // Loop as long as at least one channel might still produce data or a final status select { case <-ctx.Done(): s.logger.Debug().Int64("offset", offset).Msg("directstream: Subtitle streaming cancelled by context") + flushBatch() return + case <-ticker.C: + flushBatch() + case subtitle, ok := <-subtitleCh: if !ok { subtitleCh = nil // Mark as exhausted subtitleChannelActive = false if !errorChannelActive { // If both channels are exhausted, exit + flushBatch() return } continue // Continue to wait for errorChannel or ctx.Done() } if subtitle != nil { onFirstEventSent() - // Send the event to the player - s.manager.nativePlayer.SubtitleEvent(stream.ClientId(), subtitle) - lastSubtitleEventRWMutex.Lock() - lastSubtitleEvent = subtitle - lastSubtitleEventRWMutex.Unlock() + // Buffer the event + eventBatch = append(eventBatch, subtitle) + if len(eventBatch) >= maxBatchSize { + flushBatch() + } } case err, ok := <-errCh: @@ -173,6 +210,7 @@ func (s *BaseStream) StartSubtitleStreamP(stream Stream, playbackCtx context.Con errCh = nil // Mark as exhausted errorChannelActive = false if !subtitleChannelActive { // If both channels are exhausted, exit + flushBatch() return } continue // Continue to wait for subtitleChannel or ctx.Done() @@ -185,6 +223,7 @@ func (s *BaseStream) StartSubtitleStreamP(stream Stream, playbackCtx context.Con s.logger.Info().Int64("offset", offset).Msg("directstream: Subtitle streaming completed by parser.") subtitleStream.Stop(true) } + flushBatch() return // Terminate goroutine } } diff --git a/internal/events/endpoints.go b/internal/events/endpoints.go index 90d20868..2cc9eb13 100644 --- a/internal/events/endpoints.go +++ b/internal/events/endpoints.go @@ -10,6 +10,7 @@ const ( AnimeEntryBulkActionEndpoint = "ANIME-ENTRIES-anime-entry-bulk-action" AnimeEntryManualMatchEndpoint = "ANIME-ENTRIES-anime-entry-manual-match" CancelDiscordActivityEndpoint = "DISCORD-cancel-discord-activity" + CheckForUpdatesEndpoint = "RELEASES-check-for-updates" ClearAllChapterDownloadQueueEndpoint = "MANGA-DOWNLOAD-clear-all-chapter-download-queue" ClearFileCacheMediastreamVideoFilesEndpoint = "FILECACHE-clear-file-cache-mediastream-video-files" CreateAutoDownloaderProfileEndpoint = "AUTO-DOWNLOADER-create-auto-downloader-profile" diff --git a/internal/extension_repo/goja_plugin_types/app.d.ts b/internal/extension_repo/goja_plugin_types/app.d.ts index 9d7792f3..50107c9a 100644 --- a/internal/extension_repo/goja_plugin_types/app.d.ts +++ b/internal/extension_repo/goja_plugin_types/app.d.ts @@ -553,8 +553,7 @@ declare namespace $app { * @file internal/continuity/hook_events.go * @description * WatchHistoryItemRequestedEvent is triggered when a watch history item is requested. - * Prevent default to skip getting the watch history item from the file cache, in this case the event should have a valid WatchHistoryItem object - * or set it to nil to indicate that the watch history item was not found. + * Prevent default to skip getting the watch history item from the file cache, in this case the event should have a valid WatchHistoryItem object or set it to nil to indicate that the watch history item was not found. */ function onWatchHistoryItemRequested(cb: (event: WatchHistoryItemRequestedEvent) => void): void; @@ -701,10 +700,10 @@ declare namespace $app { * @event DiscordPresenceAnimeActivityRequestedEvent * @file internal/discordrpc/presence/hook_events.go * @description - * DiscordPresenceAnimeActivityRequestedEvent is triggered when anime activity is requested, after the [animeActivity] is processed, and right - * before the activity is sent to queue. There is no guarantee as to when or if the activity will be successfully sent to discord. Note that - * this event is triggered every 6 seconds or so, avoid heavy processing or perform it only when the activity is changed. Prevent default to - * stop the activity from being sent to discord. + * DiscordPresenceAnimeActivityRequestedEvent is triggered when anime activity is requested, after the [animeActivity] is processed, and right before the activity is sent to queue. + * There is no guarantee as to when or if the activity will be successfully sent to discord. + * Note that this event is triggered every 6 seconds or so, avoid heavy processing or perform it only when the activity is changed. + * Prevent default to stop the activity from being sent to discord. */ function onDiscordPresenceAnimeActivityRequested(cb: (event: DiscordPresenceAnimeActivityRequestedEvent) => void): void; @@ -742,10 +741,10 @@ declare namespace $app { * @event DiscordPresenceMangaActivityRequestedEvent * @file internal/discordrpc/presence/hook_events.go * @description - * DiscordPresenceMangaActivityRequestedEvent is triggered when manga activity is requested, after the [mangaActivity] is processed, and right - * before the activity is sent to queue. There is no guarantee as to when or if the activity will be successfully sent to discord. Note that - * this event is triggered every 6 seconds or so, avoid heavy processing or perform it only when the activity is changed. Prevent default to - * stop the activity from being sent to discord. + * DiscordPresenceMangaActivityRequestedEvent is triggered when manga activity is requested, after the [mangaActivity] is processed, and right before the activity is sent to queue. + * There is no guarantee as to when or if the activity will be successfully sent to discord. + * Note that this event is triggered every 6 seconds or so, avoid heavy processing or perform it only when the activity is changed. + * Prevent default to stop the activity from being sent to discord. */ function onDiscordPresenceMangaActivityRequested(cb: (event: DiscordPresenceMangaActivityRequestedEvent) => void): void; @@ -819,8 +818,9 @@ declare namespace $app { * @event HydrateOnlinestreamFillerDataRequestedEvent * @file internal/library/fillermanager/hook_events.go * @description - * HydrateOnlinestreamFillerDataRequestedEvent is triggered when the filler manager requests to hydrate the filler data for online streaming - * episodes. This is used by the online streaming episode list. Prevent default to skip the default behavior and return your own data. + * HydrateOnlinestreamFillerDataRequestedEvent is triggered when the filler manager requests to hydrate the filler data for online streaming episodes. + * This is used by the online streaming episode list. + * Prevent default to skip the default behavior and return your own data. */ function onHydrateOnlinestreamFillerDataRequested(cb: (event: HydrateOnlinestreamFillerDataRequestedEvent) => void): void; @@ -1131,9 +1131,9 @@ declare namespace $app { * @file internal/api/metadata/hook_events.go * @description * AnimeEpisodeMetadataEvent is triggered when anime episode metadata is available and is about to be returned. - * In the current implementation, episode metadata is requested for display purposes. It is used to get a more complete metadata object since the - * original AnimeMetadata object is not complete. This event is triggered after [AnimeEpisodeMetadataRequestedEvent]. If the modified episode - * metadata is nil, an empty EpisodeMetadata object will be returned. + * In the current implementation, episode metadata is requested for display purposes. It is used to get a more complete metadata object since the original AnimeMetadata object is not complete. + * This event is triggered after [AnimeEpisodeMetadataRequestedEvent]. + * If the modified episode metadata is nil, an empty EpisodeMetadata object will be returned. */ function onAnimeEpisodeMetadata(cb: (event: AnimeEpisodeMetadataEvent) => void): void; @@ -1500,8 +1500,8 @@ declare namespace $app { * PlaybackLocalFileDetailsRequestedEvent is triggered when the local files details for a specific path are requested. * This event is triggered right after the media player loads an episode. * The playback manager uses the local files details to track the progress, propose next episodes, etc. - * In the current implementation, the details are fetched by selecting the local file from the database and making requests to retrieve the media - * and anime list entry. Prevent default to skip the default fetching and override the details. + * In the current implementation, the details are fetched by selecting the local file from the database and making requests to retrieve the media and anime list entry. + * Prevent default to skip the default fetching and override the details. */ function onPlaybackLocalFileDetailsRequested(cb: (event: PlaybackLocalFileDetailsRequestedEvent) => void): void; @@ -1523,8 +1523,7 @@ declare namespace $app { * @description * PlaybackStreamDetailsRequestedEvent is triggered when the stream details are requested. * Prevent default to skip the default fetching and override the details. - * In the current implementation, the details are fetched by selecting the anime from the anime collection. If nothing is found, the stream is - * still tracked. + * In the current implementation, the details are fetched by selecting the anime from the anime collection. If nothing is found, the stream is still tracked. */ function onPlaybackStreamDetailsRequested(cb: (event: PlaybackStreamDetailsRequestedEvent) => void): void; diff --git a/internal/handlers/download.go b/internal/handlers/download.go index 8441fd49..f24ff3df 100644 --- a/internal/handlers/download.go +++ b/internal/handlers/download.go @@ -221,7 +221,7 @@ func (h *Handler) HandleDownloadMacDenshiUpdate(c echo.Context) error { return h.RespondWithError(c, fmt.Errorf("invalid download URL: %w", err)) } - if strings.ContainsAny(b.Version, "/\\..") || b.Version == "" { + if strings.ContainsAny(b.Version, "/\\") || strings.Contains(b.Version, "..") || b.Version == "" { return h.RespondWithError(c, fmt.Errorf("invalid version string")) } diff --git a/internal/handlers/releases.go b/internal/handlers/releases.go index 7d99de2b..05501d37 100644 --- a/internal/handlers/releases.go +++ b/internal/handlers/releases.go @@ -31,7 +31,7 @@ func (h *Handler) HandleInstallLatestUpdate(c echo.Context) error { go func() { time.Sleep(2 * time.Second) - h.App.SelfUpdater.StartSelfUpdate(b.FallbackDestination) + h.App.SelfUpdater.StartSelfUpdate(b.FallbackDestination, h.App.Updater.UpdateChannel) }() status := h.NewStatus(c) @@ -42,6 +42,28 @@ func (h *Handler) HandleInstallLatestUpdate(c echo.Context) error { return h.RespondWithData(c, status) } +// HandleCheckForUpdates +// +// @summary forces a re-check for updates and returns the result. +// @desc This resets the update cache and performs a fresh check for updates. +// @desc If an error occurs, it will return an empty update. +// @route /api/v1/check-for-updates [POST] +// @returns updater.Update +func (h *Handler) HandleCheckForUpdates(c echo.Context) error { + // Temporarily enable update checking (bypasses disableUpdateCheck setting) + h.App.Updater.SetEnabled(true) + defer h.App.Updater.SetEnabled(!h.App.Settings.Library.DisableUpdateCheck) + + h.App.Updater.ShouldRefetchReleases() + + update, err := h.App.Updater.GetLatestUpdate() + if err != nil { + return h.RespondWithData(c, &updater.Update{}) + } + + return h.RespondWithData(c, update) +} + // HandleGetLatestUpdate // // @summary returns the latest update. diff --git a/internal/handlers/routes.go b/internal/handlers/routes.go index aa40b5cc..4120d1f6 100644 --- a/internal/handlers/routes.go +++ b/internal/handlers/routes.go @@ -308,6 +308,7 @@ func InitRoutes(app *core.App, e *echo.Echo) { v1.POST("/install-update", h.HandleInstallLatestUpdate) v1.POST("/download-release", h.HandleDownloadRelease) v1.POST("/download-mac-denshi-update", h.HandleDownloadMacDenshiUpdate) + v1.POST("/check-for-updates", h.HandleCheckForUpdates) // // Theme diff --git a/internal/nativeplayer/events.go b/internal/nativeplayer/events.go index e332b640..6abc94de 100644 --- a/internal/nativeplayer/events.go +++ b/internal/nativeplayer/events.go @@ -37,6 +37,14 @@ func (p *NativePlayer) SubtitleEvent(clientId string, event *mkvparser.SubtitleE p.sendPlayerEventTo(clientId, string(ServerEventSubtitleEvent), event, true) } +// SubtitleEvents sends multiple subtitle events to the client. +func (p *NativePlayer) SubtitleEvents(clientId string, events []*mkvparser.SubtitleEvent) { + for _, event := range events { + p.videoCore.RecordEvent(event) + } + p.sendPlayerEventTo(clientId, string(ServerEventSubtitleEvent), events, true) +} + // SetTracks sends the set tracks event to the client. func (p *NativePlayer) SetTracks(clientId string, tracks []*mkvparser.TrackInfo) { p.sendPlayerEventTo(clientId, string(ServerEventSetTracks), tracks) diff --git a/internal/updater/selfupdate.go b/internal/updater/selfupdate.go index 3ff66292..a5e78cee 100644 --- a/internal/updater/selfupdate.go +++ b/internal/updater/selfupdate.go @@ -76,8 +76,11 @@ func (su *SelfUpdater) Started() <-chan struct{} { return su.breakLoopCh } -func (su *SelfUpdater) StartSelfUpdate(fallbackDestination string) { +func (su *SelfUpdater) StartSelfUpdate(fallbackDestination string, releaseChannel string) { su.fallbackDest = fallbackDestination + if releaseChannel != "" { + su.updater.UpdateChannel = releaseChannel + } close(su.breakLoopCh) } diff --git a/seanime-web/public/pgs-renderer.worker.js b/seanime-web/public/pgs-renderer.worker.js index e1b8bcb7..3c6f7c67 100644 --- a/seanime-web/public/pgs-renderer.worker.js +++ b/seanime-web/public/pgs-renderer.worker.js @@ -230,6 +230,10 @@ self.onmessage = async (e) => { await handleAddEvent(payload) break + case "addEvents": + await Promise.all(payload.map(ev => handleAddEvent(ev))) + break + case "render": handleRender(payload) break diff --git a/seanime-web/src/api/generated/endpoint.types.ts b/seanime-web/src/api/generated/endpoint.types.ts index 9ce0ca01..bfa74fec 100644 --- a/seanime-web/src/api/generated/endpoint.types.ts +++ b/seanime-web/src/api/generated/endpoint.types.ts @@ -735,6 +735,7 @@ export type DownloadTorrentFile_Variables = { download_urls: Array destination: string media?: AL_BaseAnime + clientId: string } /** diff --git a/seanime-web/src/api/generated/endpoints.ts b/seanime-web/src/api/generated/endpoints.ts index ad8769da..ac991045 100644 --- a/seanime-web/src/api/generated/endpoints.ts +++ b/seanime-web/src/api/generated/endpoints.ts @@ -1869,6 +1869,17 @@ export const API_ENDPOINTS = { methods: ["POST"], endpoint: "/api/v1/install-update", }, + /** + * @description + * Route forces a re-check for updates and returns the result. + * This resets the update cache and performs a fresh check for updates. + * If an error occurs, it will return an empty update. + */ + CheckForUpdates: { + key: "RELEASES-check-for-updates", + methods: ["POST"], + endpoint: "/api/v1/check-for-updates", + }, /** * @description * Route returns the latest update. diff --git a/seanime-web/src/api/generated/hooks_template.ts b/seanime-web/src/api/generated/hooks_template.ts index febe32db..bc194efe 100644 --- a/seanime-web/src/api/generated/hooks_template.ts +++ b/seanime-web/src/api/generated/hooks_template.ts @@ -2297,6 +2297,17 @@ // }) // } +// export function useCheckForUpdates() { +// return useServerMutation({ +// endpoint: API_ENDPOINTS.RELEASES.CheckForUpdates.endpoint, +// method: API_ENDPOINTS.RELEASES.CheckForUpdates.methods[0], +// mutationKey: [API_ENDPOINTS.RELEASES.CheckForUpdates.key], +// onSuccess: async () => { +// +// }, +// }) +// } + // export function useGetLatestUpdate() { // return useServerQuery({ // endpoint: API_ENDPOINTS.RELEASES.GetLatestUpdate.endpoint, diff --git a/seanime-web/src/api/generated/types.ts b/seanime-web/src/api/generated/types.ts index c30ef246..b21c10b0 100644 --- a/seanime-web/src/api/generated/types.ts +++ b/seanime-web/src/api/generated/types.ts @@ -3831,6 +3831,10 @@ export type Models_LibrarySettings = { useFallbackMetadataProvider: boolean scannerUseLegacyMatching: boolean scannerConfig: string + /** + * "github", "seanime", "seanime_nightly" + */ + updateChannel: string } /** diff --git a/seanime-web/src/api/hooks/releases.hooks.ts b/seanime-web/src/api/hooks/releases.hooks.ts index 726a2b25..64daa85c 100644 --- a/seanime-web/src/api/hooks/releases.hooks.ts +++ b/seanime-web/src/api/hooks/releases.hooks.ts @@ -34,4 +34,14 @@ export function useGetChangelog(before: string, after: string, enabled: boolean) queryKey: [API_ENDPOINTS.RELEASES.GetChangelog.key], enabled: enabled, }) -} \ No newline at end of file +} + +export function useCheckForUpdates() { + return useServerMutation({ + endpoint: API_ENDPOINTS.RELEASES.CheckForUpdates.endpoint, + method: API_ENDPOINTS.RELEASES.CheckForUpdates.methods[0], + mutationKey: [API_ENDPOINTS.RELEASES.CheckForUpdates.key], + onSuccess: async () => { + }, + }) +} diff --git a/seanime-web/src/app/(main)/_electron/electron-restart-server-prompt.tsx b/seanime-web/src/app/(main)/_electron/electron-restart-server-prompt.tsx index 122cc835..8a913570 100644 --- a/seanime-web/src/app/(main)/_electron/electron-restart-server-prompt.tsx +++ b/seanime-web/src/app/(main)/_electron/electron-restart-server-prompt.tsx @@ -71,7 +71,7 @@ export function ElectronRestartServerPrompt() { } // Server is reachable but user hasn't logged in yet - const isUnauthenticated = serverHasPassword && !serverAuthToken + const isUnauthenticated = (serverHasPassword && !serverAuthToken) || import.meta.env.MODE === "development" // Try to reconnect automatically const tryAutoReconnectRef = React.useRef(true) diff --git a/seanime-web/src/app/(main)/_electron/electron-update-modal.tsx b/seanime-web/src/app/(main)/_electron/electron-update-modal.tsx index 76edf5d6..8b76e1e6 100644 --- a/seanime-web/src/app/(main)/_electron/electron-update-modal.tsx +++ b/seanime-web/src/app/(main)/_electron/electron-update-modal.tsx @@ -23,13 +23,13 @@ type UpdateModalProps = { collapsed?: boolean } -const updateModalOpenAtom = atom(false) +export const electronUpdateModalOpenAtom = atom(false) export const isUpdateInstalledAtom = atom(false) export const isUpdatingAtom = atom(false) export function ElectronUpdateModal(props: UpdateModalProps) { const serverStatus = useServerStatus() - const [updateModalOpen, setUpdateModalOpen] = useAtom(updateModalOpenAtom) + const [updateModalOpen, setUpdateModalOpen] = useAtom(electronUpdateModalOpenAtom) const [isUpdating, setIsUpdating] = useAtom(isUpdatingAtom) const { data: updateData, isLoading, refetch } = useGetLatestUpdate(!!serverStatus && !serverStatus?.settings?.library?.disableUpdateCheck) @@ -37,9 +37,11 @@ export function ElectronUpdateModal(props: UpdateModalProps) { useWebsocketMessageListener({ type: WSEvents.CHECK_FOR_UPDATES, onMessage: () => { - refetch().then(() => { - checkElectronUpdate() - }) + if (!serverStatus?.settings?.library?.disableUpdateCheck) { + refetch().then(() => { + checkElectronUpdate() + }) + } }, }) @@ -112,7 +114,9 @@ export function ElectronUpdateModal(props: UpdateModalProps) { setIsDownloading(true) setIsDownloaded(false) setDownloadProgress(0) - toast.info("Update found, downloading...") + if (!isMacOS) { + toast.info("Update found, downloading...") + } }) return () => { @@ -126,6 +130,7 @@ export function ElectronUpdateModal(props: UpdateModalProps) { }, []) React.useEffect(() => { + if (serverStatus?.settings?.library?.disableUpdateCheck) return if (updateData && updateData.release) { setUpdateModalOpen(true) } @@ -233,9 +238,7 @@ export function ElectronUpdateModal(props: UpdateModalProps) { } } - if (serverStatus?.settings?.library?.disableUpdateCheck) return null - - if (isLoading || updateLoading || !updateData || !updateData.release) return null + if (!updateModalOpen && (serverStatus?.settings?.library?.disableUpdateCheck || isLoading || updateLoading || !updateData || !updateData.release)) return null if (isInstalled) return (
@@ -271,8 +274,8 @@ export function ElectronUpdateModal(props: UpdateModalProps) {

A new update is available!

- {updateData.current_version} - {updateData.release.version}

+ {updateData?.current_version} + {updateData?.release?.version} {!electronUpdate && !isMacOS && ( diff --git a/seanime-web/src/app/(main)/_features/native-player/native-player.tsx b/seanime-web/src/app/(main)/_features/native-player/native-player.tsx index 4e890559..92052b16 100644 --- a/seanime-web/src/app/(main)/_features/native-player/native-player.tsx +++ b/seanime-web/src/app/(main)/_features/native-player/native-player.tsx @@ -18,6 +18,9 @@ import { nativePlayer_stateAtom } from "./native-player.atoms" const log = logger("NATIVE PLAYER") +// minimum interval between subtitle event flushes +const SUBTITLE_FLUSH_INTERVAL_MS = 300 + export function NativePlayer() { const qc = useQueryClient() const clientId = useAtomValue(clientIdAtom) @@ -35,6 +38,61 @@ export function NativePlayer() { qc.invalidateQueries({ queryKey: [API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistoryItem.key] }) }, [state]) + // + // Subtitle event buffering + // Accumulate incoming subtitle events and flush them to the subtitle manager + // + + const subtitleBufferRef = React.useRef([]) + const subtitleFlushTimerRef = React.useRef | null>(null) + const subtitleIdleHandleRef = React.useRef(null) + const subtitleManagerRef = React.useRef(subtitleManager) + subtitleManagerRef.current = subtitleManager + + const flushSubtitleBuffer = React.useCallback(() => { + subtitleFlushTimerRef.current = null + subtitleIdleHandleRef.current = null + + const events = subtitleBufferRef.current + if (events.length === 0) return + subtitleBufferRef.current = [] + + // process outside the websocket message handler + subtitleManagerRef.current?.onSubtitleEvents(events)?.then() + }, []) + + const scheduleSubtitleFlush = React.useCallback(() => { + if (subtitleFlushTimerRef.current !== null) return // already scheduled + + // with a deadline so events don't pile up + if (typeof requestIdleCallback !== "undefined") { + subtitleIdleHandleRef.current = requestIdleCallback(() => { + flushSubtitleBuffer() + }, { timeout: SUBTITLE_FLUSH_INTERVAL_MS }) + } + + // guarantee a flush even if idle callback doesn't fire in time + subtitleFlushTimerRef.current = setTimeout(() => { + if (subtitleIdleHandleRef.current !== null) { + cancelIdleCallback(subtitleIdleHandleRef.current) + subtitleIdleHandleRef.current = null + } + flushSubtitleBuffer() + }, SUBTITLE_FLUSH_INTERVAL_MS) + }, [flushSubtitleBuffer]) + + // cleanup subtitle buffer timers on unmount + React.useEffect(() => { + return () => { + if (subtitleFlushTimerRef.current !== null) { + clearTimeout(subtitleFlushTimerRef.current) + } + if (subtitleIdleHandleRef.current !== null && typeof cancelIdleCallback !== "undefined") { + cancelIdleCallback(subtitleIdleHandleRef.current) + } + } + }, []) + // // Server events // @@ -83,9 +141,15 @@ export function NativePlayer() { setMiniPlayer(false) break // 3. Subtitle event (MKV) - // We receive the subtitle events after the server received the loaded-metadata event + // We receive the subtitle events after the server received the loaded-metadata event. + // Buffer the events and process them off the main thread case "subtitle-event": - subtitleManager?.onSubtitleEvent(payload as MKVParser_SubtitleEvent)?.then() + if (Array.isArray(payload)) { + subtitleBufferRef.current.push(...(payload as MKVParser_SubtitleEvent[])) + } else { + subtitleBufferRef.current.push(payload as MKVParser_SubtitleEvent) + } + scheduleSubtitleFlush() break case "error": log.error("Error event received", payload) diff --git a/seanime-web/src/app/(main)/_features/update/update-modal.tsx b/seanime-web/src/app/(main)/_features/update/update-modal.tsx index cfa4e424..7b418fe6 100644 --- a/seanime-web/src/app/(main)/_features/update/update-modal.tsx +++ b/seanime-web/src/app/(main)/_features/update/update-modal.tsx @@ -40,7 +40,9 @@ export function UpdateModal(props: UpdateModalProps) { useWebsocketMessageListener({ type: WSEvents.CHECK_FOR_UPDATES, onMessage: () => { - refetch() + if (!serverStatus?.settings?.library?.disableUpdateCheck) { + refetch() + } }, }) @@ -49,6 +51,7 @@ export function UpdateModal(props: UpdateModalProps) { const [fallbackDestination, setFallbackDestination] = React.useState("") React.useEffect(() => { + if (serverStatus?.settings?.library?.disableUpdateCheck) return if (updateData && updateData.release) { localStorage.setItem("latest-available-update", JSON.stringify(updateData.release.version)) const latestVersionNotified = localStorage.getItem("notified-available-update") @@ -69,9 +72,7 @@ export function UpdateModal(props: UpdateModalProps) { installUpdate({ fallback_destination: "" }) } - if (serverStatus?.settings?.library?.disableUpdateCheck) return null - - if (isLoading || !updateData || !updateData.release) return null + if (!updateModalOpen && (serverStatus?.settings?.library?.disableUpdateCheck || isLoading || !updateData || !updateData.release)) return null return ( <> @@ -91,13 +92,13 @@ export function UpdateModal(props: UpdateModalProps) { onOpenChange={() => ignoreUpdate()} contentClass="max-w-3xl" > - +

A new update is available!

- {updateData.current_version} - {updateData.release.version}

+ {updateData?.current_version} + {updateData?.release?.version} {serverStatus?.isDesktopSidecar && containerRatio) { - displayedHeight = containerHeight - displayedWidth = containerHeight * videoRatio - } else { - displayedWidth = containerWidth - displayedHeight = containerWidth / videoRatio - } - } else if (objectFit === "contain") { - if (videoRatio > containerRatio) { - displayedWidth = containerWidth - displayedHeight = containerWidth / videoRatio - } else { - displayedHeight = containerHeight - displayedWidth = containerHeight * videoRatio - } - } else { - // object-fit: fill or none or scale-down, fallback + if (videoRatio > containerRatio) { displayedWidth = containerWidth + displayedHeight = containerWidth / videoRatio + } else { displayedHeight = containerHeight + displayedWidth = containerHeight * videoRatio } return { displayedWidth, displayedHeight } @@ -413,6 +399,12 @@ export class VideoCoreAnime4KManager extends EventTarget { private _createCanvas() { if (this._abortController?.signal.aborted) return + const rect = this.videoElement.getBoundingClientRect() + const videoContentSize = this.getRenderedVideoContentSize(this.videoElement, rect.width, rect.height) + if (videoContentSize) { + this._boxSize = { width: videoContentSize.displayedWidth, height: videoContentSize.displayedHeight } + } + this.canvas = document.createElement("canvas") this.canvas.width = this._boxSize.width @@ -473,22 +465,52 @@ export class VideoCoreAnime4KManager extends EventTarget { }) setTimeout(() => { - if (this.canvas) { - for (const callback of this._onCanvasCreatedCallbacks) { - callback(this.canvas) - } - for (const callback of this._onCanvasCreatedCallbacksOnce) { - callback(this.canvas) - } - this._onCanvasCreatedCallbacksOnce.clear() + requestAnimationFrame(() => { + if (this.canvas) { + const rect = this.videoElement.getBoundingClientRect() + const videoContentSize = this.getRenderedVideoContentSize(this.videoElement, rect.width, rect.height) + if (videoContentSize && (videoContentSize.displayedWidth !== this._boxSize.width || videoContentSize.displayedHeight !== this._boxSize.height)) { + this._boxSize = { width: videoContentSize.displayedWidth, height: videoContentSize.displayedHeight } + this.canvas.width = this._boxSize.width + this.canvas.height = this._boxSize.height + this.canvas.style.width = this._boxSize.width + "px" + this.canvas.style.height = this._boxSize.height + "px" + log.info("Post-init canvas resize correction", { ...this._boxSize }) + } - const event: Anime4KManagerCanvasCreatedEvent = new CustomEvent("canvascreated", { detail: { canvas: this.canvas } }) - this.dispatchEvent(event) + for (const callback of this._onCanvasCreatedCallbacks) { + callback(this.canvas) + } + for (const callback of this._onCanvasCreatedCallbacksOnce) { + callback(this.canvas) + } + this._onCanvasCreatedCallbacksOnce.clear() - this._startRenderFpsTracking() - } + const event: Anime4KManagerCanvasCreatedEvent = new CustomEvent("canvascreated", { detail: { canvas: this.canvas } }) + this.dispatchEvent(event) + + this._startRenderFpsTracking() + } + }) }, 100) + setTimeout(() => { + requestAnimationFrame(() => { + if (this.canvas && !this._abortController?.signal.aborted) { + const rect = this.videoElement.getBoundingClientRect() + const videoContentSize = this.getRenderedVideoContentSize(this.videoElement, rect.width, rect.height) + if (videoContentSize && (videoContentSize.displayedWidth !== this._boxSize.width || videoContentSize.displayedHeight !== this._boxSize.height)) { + this._boxSize = { width: videoContentSize.displayedWidth, height: videoContentSize.displayedHeight } + this.canvas.width = this._boxSize.width + this.canvas.height = this._boxSize.height + this.canvas.style.width = this._boxSize.width + "px" + this.canvas.style.height = this._boxSize.height + "px" + log.info("Deferred canvas resize correction", { ...this._boxSize }) + } + } + }) + }, 500) + // Start frame drop detection if enabled if (this._frameDropState.enabled && this._isOptionSelected(this._currentOption)) { this._startFrameDropDetection() diff --git a/seanime-web/src/app/(main)/_features/video-core/video-core-anime-4k.ts b/seanime-web/src/app/(main)/_features/video-core/video-core-anime-4k.ts index 15636f1a..cc99d3d9 100644 --- a/seanime-web/src/app/(main)/_features/video-core/video-core-anime-4k.ts +++ b/seanime-web/src/app/(main)/_features/video-core/video-core-anime-4k.ts @@ -49,7 +49,7 @@ export const VideoCoreAnime4K = () => { // Handle option changes React.useLayoutEffect(() => { if (video && manager) { - manager.resize() + manager.resize(realVideoSize.width, realVideoSize.height) } }, [realVideoSize]) diff --git a/seanime-web/src/app/(main)/_features/video-core/video-core-control-bar.tsx b/seanime-web/src/app/(main)/_features/video-core/video-core-control-bar.tsx index 4be2bd27..7575fdca 100644 --- a/seanime-web/src/app/(main)/_features/video-core/video-core-control-bar.tsx +++ b/seanime-web/src/app/(main)/_features/video-core/video-core-control-bar.tsx @@ -178,7 +178,7 @@ export function VideoCoreControlBar(props: { data-vc-state-visible={!hideControlBar} className={cn( "absolute left-0 bottom-0 right-0 flex flex-col text-white", - "transition-all duration-300 opacity-0", + "transition-[opacity,transform] duration-300 opacity-0", "z-[10] h-28 transform-gpu", !hideControlBar && "opacity-100", VIDEOCORE_DEBUG_ELEMENTS && "bg-purple-500/20", @@ -302,7 +302,7 @@ export function VideoCoreMobileControlBar(props: { data-vc-element="mobile-control-bar-top-section" className={cn( "vc-mobile-control-bar-top-section", - "absolute transition-all left-0 right-0 top-0 w-full z-[11] transform-gpu", + "absolute transition-transform left-0 right-0 top-0 w-full z-[11] transform-gpu", "px-2 pt-3", VIDEOCORE_DEBUG_ELEMENTS && "bg-purple-800/40", )} @@ -327,7 +327,7 @@ export function VideoCoreMobileControlBar(props: { data-vc-element="mobile-control-bar-bottom-section" className={cn( "vc-mobile-control-bar-bottom-section", - "absolute transition-all left-0 right-0 bottom-0 w-full z-[11] transform-gpu", + "absolute transition-transform left-0 right-0 bottom-0 w-full z-[11] transform-gpu", "px-2", VIDEOCORE_DEBUG_ELEMENTS && "bg-purple-800/40", isSwiping && "transition-none", @@ -545,7 +545,7 @@ export function VideoCoreVolumeButton() { "flex h-full w-full relative items-center", "rounded-full", "cursor-pointer", - "transition-all duration-300", + "transition-[width,background-color] duration-300", )} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} @@ -630,7 +630,7 @@ export function VideoCoreTimestamp() { data-vc-element="timestamp" data-vc-timestamp-type={type} className={cn( - "font-medium opacity-100 cursor-pointer", + "tabular-nums font-medium opacity-100 cursor-pointer", isMobile ? "text-xs text-white" : "text-sm hover:opacity-80", )} onClick={handleSwitchType} diff --git a/seanime-web/src/app/(main)/_features/video-core/video-core-in-sight.tsx b/seanime-web/src/app/(main)/_features/video-core/video-core-in-sight.tsx index a8d66d6d..8f8bdf5f 100644 --- a/seanime-web/src/app/(main)/_features/video-core/video-core-in-sight.tsx +++ b/seanime-web/src/app/(main)/_features/video-core/video-core-in-sight.tsx @@ -255,7 +255,7 @@ export function VideoCoreInSight() {
diff --git a/seanime-web/src/app/(main)/_features/video-core/video-core-media-captions.ts b/seanime-web/src/app/(main)/_features/video-core/video-core-media-captions.ts index beadaf98..832ce73d 100644 --- a/seanime-web/src/app/(main)/_features/video-core/video-core-media-captions.ts +++ b/seanime-web/src/app/(main)/_features/video-core/video-core-media-captions.ts @@ -598,7 +598,7 @@ export class MediaCaptionsManager extends EventTarget { this.wrapperElement.style.pointerEvents = "none" this.wrapperElement.style.zIndex = "10" this.wrapperElement.style.overflow = "hidden" - this.wrapperElement.classList.add("transform-gpu", "transition-all", "duration-300", "ease-in-out") + this.wrapperElement.classList.add("transform-gpu", "transition-transform", "duration-300", "ease-in-out") this.videoElement.parentElement?.appendChild(this.wrapperElement) // Create overlay element for captions diff --git a/seanime-web/src/app/(main)/_features/video-core/video-core-pgs-renderer.ts b/seanime-web/src/app/(main)/_features/video-core/video-core-pgs-renderer.ts index 02cf3d66..66a36d54 100644 --- a/seanime-web/src/app/(main)/_features/video-core/video-core-pgs-renderer.ts +++ b/seanime-web/src/app/(main)/_features/video-core/video-core-pgs-renderer.ts @@ -53,12 +53,26 @@ export class VideoCorePgsRenderer { }) } + addEvents(events: PgsEvent[]) { + if (!this._worker || events.length === 0) { + return + } + + this._worker.postMessage({ + type: "addEvents", + payload: events, + }) + } + resize() { if (!this._canvas || !this._worker) { return } - const videoContentSize = this._getRenderedVideoContentSize() + const videoContentSize = this._getRenderedVideoContentSize( + this._videoElement.clientWidth, + this._videoElement.clientHeight, + ) if (!videoContentSize) { return } @@ -165,7 +179,10 @@ export class VideoCorePgsRenderer { } // Set initial size before transferring - const videoContentSize = this._getRenderedVideoContentSize() + const videoContentSize = this._getRenderedVideoContentSize( + this._videoElement.clientWidth, + this._videoElement.clientHeight, + ) if (videoContentSize) { const { displayedWidth, displayedHeight, offsetX, offsetY } = videoContentSize this._canvas.width = displayedWidth @@ -214,8 +231,36 @@ export class VideoCorePgsRenderer { private _setupResizeObserver() { if (!this._videoElement || !this._canvas) return - this._resizeObserver = new ResizeObserver(() => { - this.resize() + let resizeRafId: number | null = null + this._resizeObserver = new ResizeObserver((entries) => { + if (!this._canvas || !this._worker || !entries[0]) { + return + } + // batch reads and writes + if (resizeRafId !== null) return + const { width, height } = entries[0].contentRect + resizeRafId = requestAnimationFrame(() => { + resizeRafId = null + if (!this._canvas || !this._worker) return + const videoContentSize = this._getRenderedVideoContentSize(width, height) + if (!videoContentSize) return + + const { displayedWidth, displayedHeight, offsetX, offsetY } = videoContentSize + this._canvasWidth = displayedWidth + this._canvasHeight = displayedHeight + // batch all style writes together + this._canvas.style.width = `${displayedWidth}px` + this._canvas.style.height = `${displayedHeight}px` + this._canvas.style.left = `${offsetX}px` + this._canvas.style.top = `${offsetY}px` + this._worker.postMessage({ + type: "resize", + payload: { width: displayedWidth, height: displayedHeight }, + }) + if (this._debug) { + log.info("Resized canvas", { width: displayedWidth, height: displayedHeight, left: offsetX, top: offsetY }) + } + }) }) this._resizeObserver.observe(this._videoElement) @@ -247,10 +292,7 @@ export class VideoCorePgsRenderer { } - private _getRenderedVideoContentSize() { - const containerWidth = this._videoElement.clientWidth - const containerHeight = this._videoElement.clientHeight - + private _getRenderedVideoContentSize(containerWidth: number, containerHeight: number) { const videoWidth = this._videoElement.videoWidth const videoHeight = this._videoElement.videoHeight @@ -264,32 +306,14 @@ export class VideoCorePgsRenderer { let offsetX = 0 let offsetY = 0 - const objectFit = getComputedStyle(this._videoElement).objectFit || "contain" - - if (objectFit === "cover") { - if (videoRatio > containerRatio) { - displayedHeight = containerHeight - displayedWidth = containerHeight * videoRatio - offsetX = (containerWidth - displayedWidth) / 2 - } else { - displayedWidth = containerWidth - displayedHeight = containerWidth / videoRatio - offsetY = (containerHeight - displayedHeight) / 2 - } - } else if (objectFit === "contain") { - if (videoRatio > containerRatio) { - displayedWidth = containerWidth - displayedHeight = containerWidth / videoRatio - offsetY = (containerHeight - displayedHeight) / 2 - } else { - displayedHeight = containerHeight - displayedWidth = containerHeight * videoRatio - offsetX = (containerWidth - displayedWidth) / 2 - } - } else { - // object-fit: fill or none + if (videoRatio > containerRatio) { displayedWidth = containerWidth + displayedHeight = containerWidth / videoRatio + offsetY = (containerHeight - displayedHeight) / 2 + } else { displayedHeight = containerHeight + displayedWidth = containerHeight * videoRatio + offsetX = (containerWidth - displayedWidth) / 2 } return { displayedWidth, displayedHeight, offsetX, offsetY } diff --git a/seanime-web/src/app/(main)/_features/video-core/video-core-preferences.tsx b/seanime-web/src/app/(main)/_features/video-core/video-core-preferences.tsx index 21ddd189..da42f970 100644 --- a/seanime-web/src/app/(main)/_features/video-core/video-core-preferences.tsx +++ b/seanime-web/src/app/(main)/_features/video-core/video-core-preferences.tsx @@ -599,7 +599,7 @@ export function VideoCorePreferencesModal({ isWebPlayer }: { isWebPlayer: boolea e.stopPropagation()} onInput={(e) => e.stopPropagation()} help="Subtitle tracks that will not be selected by default if they match the preferred lanauges. Separate multiple names with commas." diff --git a/seanime-web/src/app/(main)/_features/video-core/video-core-subtitles.ts b/seanime-web/src/app/(main)/_features/video-core/video-core-subtitles.ts index ac8c5c55..27b975c2 100644 --- a/seanime-web/src/app/(main)/_features/video-core/video-core-subtitles.ts +++ b/seanime-web/src/app/(main)/_features/video-core/video-core-subtitles.ts @@ -523,31 +523,59 @@ Style: Default, Roboto Medium,24,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0 } } - // This will record the events and add them to the libass renderer if they are new. - async onSubtitleEvent(event: MKVParser_SubtitleEvent) { - // Check if this is a PGS event - if (isPGS(event.codecID)) { - this._handlePgsEvent(event) - return - } + // This will record the events and add them to the renderers if they are new. + async onSubtitleEvents(events: MKVParser_SubtitleEvent[]) { + const pgsEvents: any[] = [] + const assEvents: CachedEvent[] = [] - // Record the event - const { isNew, cachedEntry } = this._recordSubtitleEvent(event) - if (!cachedEntry) return - - // If the event belongs to the active track, render it - if (event.trackNumber === this.currentTrackNumber && this.libassRenderer && isNew) { - if (this.shouldTranslate) { - if (cachedEntry.translatedAssEvent) { - // already translated, use it - this.libassRenderer?.renderer?.createEvent(cachedEntry.translatedAssEvent) - } else { - // fetch the translation, it will be rendered once returned - this._fetchEventTranslationIfNeeded(cachedEntry) + for (const event of events) { + // Check if this is a PGS event + if (isPGS(event.codecID)) { + const isNew = this._handlePgsEvent(event, false) + if (isNew && event.trackNumber === this.currentTrackNumber && this.pgsRenderer) { + pgsEvents.push({ + startTime: event.startTime / 1e3, + duration: event.duration / 1e3, + imageData: event.text, // base64 PNG + width: parseInt(event.extraData?.width || "0", 10), + height: parseInt(event.extraData?.height || "0", 10), + x: event.extraData?.x ? parseInt(event.extraData.x, 10) : undefined, + y: event.extraData?.y ? parseInt(event.extraData.y, 10) : undefined, + canvasWidth: event.extraData?.canvas_width ? parseInt(event.extraData.canvas_width, 10) : undefined, + canvasHeight: event.extraData?.canvas_height ? parseInt(event.extraData.canvas_height, 10) : undefined, + cropX: event.extraData?.crop_x ? parseInt(event.extraData.crop_x, 10) : undefined, + cropY: event.extraData?.crop_y ? parseInt(event.extraData.crop_y, 10) : undefined, + cropWidth: event.extraData?.crop_width ? parseInt(event.extraData.crop_width, 10) : undefined, + cropHeight: event.extraData?.crop_height ? parseInt(event.extraData.crop_height, 10) : undefined, + }) } } else { - // normal flow - this.libassRenderer.renderer.createEvent(cachedEntry.assEvent) + // Record the event + const { isNew, cachedEntry } = this._recordSubtitleEvent(event) + if (isNew && cachedEntry && event.trackNumber === this.currentTrackNumber && this.libassRenderer) { + assEvents.push(cachedEntry) + } + } + } + + if (pgsEvents.length > 0 && this.pgsRenderer) { + this.pgsRenderer.addEvents(pgsEvents) + } + + if (assEvents.length > 0 && this.libassRenderer) { + for (const cachedEntry of assEvents) { + if (this.shouldTranslate) { + if (cachedEntry.translatedAssEvent) { + // already translated, use it + this.libassRenderer.renderer.createEvent(cachedEntry.translatedAssEvent) + } else { + // fetch the translation, it will be rendered once returned + this._fetchEventTranslationIfNeeded(cachedEntry) + } + } else { + // not translating, use the original event + this.libassRenderer.renderer.createEvent(cachedEntry.assEvent) + } } } } @@ -707,11 +735,11 @@ Style: Default, Roboto Medium,24,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0 await this.selectTrack(defaultTrackNumber) } - private _handlePgsEvent(event: MKVParser_SubtitleEvent) { + private _handlePgsEvent(event: MKVParser_SubtitleEvent, renderImmediately = true) { // Ensure the PGS track exists if (!this.pgsEventTracks[event.trackNumber]) { subtitleLog.warning("PGS track not initialized for track number", event.trackNumber) - return + return false } const trackEventMap = this.pgsEventTracks[event.trackNumber].events @@ -719,16 +747,18 @@ Style: Default, Roboto Medium,24,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0 // Check if the event is already recorded if (trackEventMap.has(eventKey)) { - return + return false } // Store the event trackEventMap.set(eventKey, event) // If this is the currently selected track, add the event to the renderer - if (event.trackNumber === this.currentTrackNumber && this.pgsRenderer) { + if (renderImmediately && event.trackNumber === this.currentTrackNumber && this.pgsRenderer) { this._addPgsEvent(event) } + + return true } private _getPgsEventKey(event: MKVParser_SubtitleEvent): string { @@ -865,7 +895,16 @@ Style: Default, Roboto Medium,24,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0 if (event.extraData && event.extraData["_id"]) { return event.extraData["_id"] } - return JSON.stringify(event) + return `${event.trackNumber}:${event.startTime}:${event.duration}:${this.__fastStringHash(event.text)}` + } + + // djb2 hash for string hashing + private __fastStringHash(str: string): number { + let hash = 5381 + for (let i = 0, len = str.length; i < len; i++) { + hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0 + } + return hash >>> 0 } // Stores the styles for each track. diff --git a/seanime-web/src/app/(main)/_features/video-core/video-core-top-section.tsx b/seanime-web/src/app/(main)/_features/video-core/video-core-top-section.tsx index a87c666d..0f4f284c 100644 --- a/seanime-web/src/app/(main)/_features/video-core/video-core-top-section.tsx +++ b/seanime-web/src/app/(main)/_features/video-core/video-core-top-section.tsx @@ -25,7 +25,7 @@ export function VideoCoreTopSection(props: { children?: React.ReactNode, inline?
() + // measure video element size using a throttled ResizeObserver + const videoResizeObserverRef = React.useRef(null) + const videoResizeRafRef = React.useRef(null) + const videoResizeTargetRef = React.useRef(null) + React.useEffect(() => { - setRealVideoSize({ - width, - height, + videoResizeObserverRef.current = new ResizeObserver((entries) => { + if (videoResizeRafRef.current !== null) return // already scheduled + videoResizeRafRef.current = requestAnimationFrame(() => { + videoResizeRafRef.current = null + const entry = entries[0] + if (!entry) return + const { width: w, height: h } = entry.contentRect + setRealVideoSize(prev => { + if (prev.width === w && prev.height === h) return prev + return { width: w, height: h } + }) + }) }) - }, [width, height]) + + return () => { + videoResizeObserverRef.current?.disconnect() + if (videoResizeRafRef.current !== null) { + cancelAnimationFrame(videoResizeRafRef.current) + } + } + }, []) // refetch continuity data when playback info changes React.useEffect(() => { @@ -787,7 +806,14 @@ export function VideoCore(props: VideoCoreProps) { if (mRef) { mRef.current = instance } - if (instance) measureRef(instance) + // observe/unobserve the video element for size changes + if (videoResizeTargetRef.current && videoResizeTargetRef.current !== instance) { + videoResizeObserverRef.current?.unobserve(videoResizeTargetRef.current) + } + if (instance) { + videoResizeObserverRef.current?.observe(instance) + } + videoResizeTargetRef.current = instance setVideoElement(instance) } diff --git a/seanime-web/src/app/(main)/_features/video-core/video-core.utils.ts b/seanime-web/src/app/(main)/_features/video-core/video-core.utils.ts index 6e47177a..3096c1cd 100644 --- a/seanime-web/src/app/(main)/_features/video-core/video-core.utils.ts +++ b/seanime-web/src/app/(main)/_features/video-core/video-core.utils.ts @@ -71,25 +71,88 @@ export function useVideoCoreBindings(videoRef: React.MutableRefObject { if (!videoRef.current) return const v = videoRef.current + const prev = prevRef.current + const handler = () => { - setVideoSize({ - width: v.videoWidth, - height: v.videoHeight, - }) - setDuration(v.duration) - setCurrentTime(v.currentTime) - setPlaybackRate(v.playbackRate) - setReadyState(v.readyState) + // only update atoms when values actually changed + if (prev.videoWidth !== v.videoWidth || prev.videoHeight !== v.videoHeight) { + prev.videoWidth = v.videoWidth + prev.videoHeight = v.videoHeight + setVideoSize({ width: v.videoWidth, height: v.videoHeight }) + } + if (prev.duration !== v.duration) { + prev.duration = v.duration + setDuration(v.duration) + } + if (prev.currentTime !== v.currentTime) { + prev.currentTime = v.currentTime + setCurrentTime(v.currentTime) + } + if (prev.playbackRate !== v.playbackRate) { + prev.playbackRate = v.playbackRate + setPlaybackRate(v.playbackRate) + } + if (prev.readyState !== v.readyState) { + prev.readyState = v.readyState + setReadyState(v.readyState) + } // Set buffering to true if readyState is less than HAVE_ENOUGH_DATA (3) and video is not paused - setBuffering(v.readyState < 3 && !v.paused) - setIsMuted(v.muted) - setVolume(v.volume) - setBuffered(v.buffered.length > 0 ? v.buffered : null) - setEnded(v.ended) - setPaused(v.paused) + const isBuffering = v.readyState < 3 && !v.paused + if (prev.buffering !== isBuffering) { + prev.buffering = isBuffering + setBuffering(isBuffering) + } + if (prev.isMuted !== v.muted) { + prev.isMuted = v.muted + setIsMuted(v.muted) + } + if (prev.volume !== v.volume) { + prev.volume = v.volume + setVolume(v.volume) + } + // only check buffered ranges if the length changed (skip expensive comparison) + const bufferedLen = v.buffered.length + if (prev.bufferedLength !== bufferedLen) { + prev.bufferedLength = bufferedLen + setBuffered(v.buffered) + } else if (bufferedLen > 0) { + setBuffered(prevRanges => { + const current = v.buffered + if (!prevRanges || prevRanges.length !== current.length) return current + for (let i = 0; i < current.length; i++) { + if (prevRanges.start(i) !== current.start(i) || prevRanges.end(i) !== current.end(i)) { + return current + } + } + return prevRanges + }) + } + if (prev.ended !== v.ended) { + prev.ended = v.ended + setEnded(v.ended) + } + if (prev.paused !== v.paused) { + prev.paused = v.paused + setPaused(v.paused) + } } const events = ["timeupdate", "loadedmetadata", "progress", "play", "pause", "ratechange", "volumechange", "ended", "loadeddata", "resize", "waiting", "canplay", "stalled"] diff --git a/seanime-web/src/app/(main)/settings/_containers/denshi-settings.tsx b/seanime-web/src/app/(main)/settings/_containers/denshi-settings.tsx index b322b55b..26d538f6 100644 --- a/seanime-web/src/app/(main)/settings/_containers/denshi-settings.tsx +++ b/seanime-web/src/app/(main)/settings/_containers/denshi-settings.tsx @@ -19,7 +19,7 @@ export function DenshiSettings() { } }, []) - function updateSetting(key: keyof DenshiSettings, value: boolean) { + function updateSetting(key: keyof DenshiSettings, value: boolean | string) { if (!settingsRef.current || !window.electron?.denshiSettings) return const newSettings = { ...settingsRef.current, [key]: value } diff --git a/seanime-web/src/app/(main)/settings/_containers/server-settings.tsx b/seanime-web/src/app/(main)/settings/_containers/server-settings.tsx index 668a605d..fee97480 100644 --- a/seanime-web/src/app/(main)/settings/_containers/server-settings.tsx +++ b/seanime-web/src/app/(main)/settings/_containers/server-settings.tsx @@ -311,10 +311,20 @@ export function ServerSettings(props: ServerSettingsProps) { label={__isElectronDesktop__ ? "Do not fetch update notes" : "Do not check for updates"} help={__isElectronDesktop__ ? ( - If enabled, new releases won't be displayed. Seanime Denshi will still auto-update in the background. + If enabled, new releases won't be displayed. Seanime Denshi may still auto-update in the background. ) : "If enabled, Seanime will not check for new releases."} moreHelp={__isElectronDesktop__ ? "You cannot disable auto-updates for Seanime Denshi." : undefined} /> + {/* { if (!isPending && !!data?.settings) { setServerStatus(data) @@ -332,6 +344,7 @@ export default function Page() { useFallbackMetadataProvider: data.useFallbackMetadataProvider ?? false, scannerUseLegacyMatching: data.scannerUseLegacyMatching ?? false, scannerConfig: data.scannerConfig ?? "", + updateChannel: data.updateChannel || "github", }, nakama: { enabled: data.nakamaEnabled ?? false, @@ -410,6 +423,16 @@ export default function Page() { }, { onSuccess: () => { formRef.current?.reset(formRef.current.getValues()) + + // Sync updateChannel to Denshi + if (__isElectronDesktop__ && window.electron?.denshiSettings) { + window.electron.denshiSettings.get().then((denshiSettings) => { + window.electron!.denshiSettings.set({ + ...denshiSettings, + updateChannel: data.updateChannel || "github", + }) + }) + } }, }) }} @@ -499,6 +522,7 @@ export default function Page() { vcTranslateTargetLanguage: status?.settings?.mediaPlayer?.vcTranslateTargetLanguage ?? "", scannerUseLegacyMatching: status?.settings?.library?.scannerUseLegacyMatching ?? false, scannerConfig: status?.settings?.library?.scannerConfig ?? "", + updateChannel: status?.settings?.library?.updateChannel || "github", }} stackClass="space-y-0 relative" > @@ -536,6 +560,38 @@ export default function Page() { > Record an issue +
diff --git a/seanime-web/src/lib/server/settings.ts b/seanime-web/src/lib/server/settings.ts index afc21caa..2d80eb86 100644 --- a/seanime-web/src/lib/server/settings.ts +++ b/seanime-web/src/lib/server/settings.ts @@ -118,6 +118,7 @@ export const settingsSchema = z.object({ vcTranslateTargetLanguage: z.string().optional().default(""), scannerUseLegacyMatching: z.boolean().optional().default(false), scannerConfig: z.string().optional().default(""), + updateChannel: z.string().optional().default("github"), }) export const gettingStartedSchema = _gettingStartedSchema.extend(settingsSchema.shape) @@ -149,6 +150,7 @@ export const getDefaultSettings = (data: z.infer): useFallbackMetadataProvider: false, scannerUseLegacyMatching: false, scannerConfig: "", + updateChannel: "github", }, nakama: { enabled: false, diff --git a/seanime-web/src/main.tsx b/seanime-web/src/main.tsx index 8a7f8bea..236d659c 100644 --- a/seanime-web/src/main.tsx +++ b/seanime-web/src/main.tsx @@ -24,13 +24,12 @@ declare module "@tanstack/react-router" { } } -if (import.meta.env.DEV) { - const script = document.createElement("script") - script.src = "https://unpkg.com/react-scan/dist/auto.global.js" - script.crossOrigin = "anonymous" - document.head.appendChild(script) -} - +// if (import.meta.env.DEV) { +// const script = document.createElement("script") +// script.src = "https://unpkg.com/react-scan/dist/auto.global.js" +// script.crossOrigin = "anonymous" +// document.head.appendChild(script) +// } ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/seanime-web/src/types/index.d.ts b/seanime-web/src/types/index.d.ts index f6e28728..97fa3741 100644 --- a/seanime-web/src/types/index.d.ts +++ b/seanime-web/src/types/index.d.ts @@ -80,5 +80,6 @@ declare global { minimizeToTray: boolean; openInBackground: boolean; openAtLaunch: boolean; + updateChannel?: string; } }