feat: fixed videocore perf

feat: update channels
This commit is contained in:
5rahim
2026-03-01 19:49:38 +01:00
parent 0faf965e01
commit d1f0bf6271
38 changed files with 719 additions and 192 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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
}
}

View File

@@ -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"

View File

@@ -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;

View File

@@ -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"))
}

View File

@@ -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.

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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

View File

@@ -735,6 +735,7 @@ export type DownloadTorrentFile_Variables = {
download_urls: Array<string>
destination: string
media?: AL_BaseAnime
clientId: string
}
/**

View File

@@ -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.

View File

@@ -2297,6 +2297,17 @@
// })
// }
// export function useCheckForUpdates() {
// return useServerMutation<Updater_Update>({
// 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<Updater_Update>({
// endpoint: API_ENDPOINTS.RELEASES.GetLatestUpdate.endpoint,

View File

@@ -3831,6 +3831,10 @@ export type Models_LibrarySettings = {
useFallbackMetadataProvider: boolean
scannerUseLegacyMatching: boolean
scannerConfig: string
/**
* "github", "seanime", "seanime_nightly"
*/
updateChannel: string
}
/**

View File

@@ -34,4 +34,14 @@ export function useGetChangelog(before: string, after: string, enabled: boolean)
queryKey: [API_ENDPOINTS.RELEASES.GetChangelog.key],
enabled: enabled,
})
}
}
export function useCheckForUpdates() {
return useServerMutation<Updater_Update>({
endpoint: API_ENDPOINTS.RELEASES.CheckForUpdates.endpoint,
method: API_ENDPOINTS.RELEASES.CheckForUpdates.methods[0],
mutationKey: [API_ENDPOINTS.RELEASES.CheckForUpdates.key],
onSuccess: async () => {
},
})
}

View File

@@ -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)

View File

@@ -23,13 +23,13 @@ type UpdateModalProps = {
collapsed?: boolean
}
const updateModalOpenAtom = atom<boolean>(false)
export const electronUpdateModalOpenAtom = atom<boolean>(false)
export const isUpdateInstalledAtom = atom<boolean>(false)
export const isUpdatingAtom = atom<boolean>(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 (
<div className="fixed top-0 left-0 w-full h-full bg-[--background] flex items-center z-[9999]">
@@ -271,8 +274,8 @@ export function ElectronUpdateModal(props: UpdateModalProps) {
<div className="space-y-2">
<h3 className="text-center">A new update is available!</h3>
<h4 className="font-bold flex gap-2 text-center items-center justify-center">
<span className="text-[--muted]">{updateData.current_version}</span> <FiArrowRight />
<span className="text-indigo-200">{updateData.release.version}</span></h4>
<span className="text-[--muted]">{updateData?.current_version}</span> <FiArrowRight />
<span className="text-indigo-200">{updateData?.release?.version}</span></h4>
{!electronUpdate && !isMacOS && (
<Alert intent="warning">

View File

@@ -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<MKVParser_SubtitleEvent[]>([])
const subtitleFlushTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
const subtitleIdleHandleRef = React.useRef<number | null>(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)

View File

@@ -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<string>("")
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"
>
<Downloader release={updateData.release} />
<Downloader release={updateData?.release} />
<div className="space-y-2">
<h3 className="text-center">A new update is available!</h3>
<h4 className="font-bold flex gap-2 text-center items-center justify-center">
<span className="text-[--muted]">{updateData.current_version}</span> <FiArrowRight />
<span className="text-indigo-200">{updateData.release.version}</span></h4>
<span className="text-[--muted]">{updateData?.current_version}</span> <FiArrowRight />
<span className="text-indigo-200">{updateData?.release?.version}</span></h4>
{serverStatus?.isDesktopSidecar && <Alert
intent="info"

View File

@@ -65,7 +65,7 @@ export function EpisodePillsGrid({
className={cn(
"relative flex items-center justify-center",
"w-full h-10 rounded-md font-medium text-sm",
"transition-all duration-150 ease-out",
"transition-colors duration-150 ease-out",
"focus:outline-none",
!isSelected && [
"bg-[--subtle]",

View File

@@ -157,12 +157,17 @@ export class VideoCoreAnime4KManager extends EventTarget {
super.removeEventListener(type, listener, options)
}
updateCanvasSize(size: { width: number; height: number }) {
const videoContentSize = this.getRenderedVideoContentSize(this.videoElement)
this._boxSize = { width: videoContentSize?.displayedWidth || size.width, height: videoContentSize?.displayedHeight || size.height }
updateCanvasSize(_size: { width: number; height: number }) {
const rect = this.videoElement.getBoundingClientRect()
const containerWidth = rect.width
const containerHeight = rect.height
const videoContentSize = this.getRenderedVideoContentSize(this.videoElement, containerWidth, containerHeight)
this._boxSize = { width: videoContentSize?.displayedWidth || containerWidth, height: videoContentSize?.displayedHeight || containerHeight }
if (this.canvas) {
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("Updating canvas size", { ...this._boxSize })
}
@@ -171,8 +176,8 @@ export class VideoCoreAnime4KManager extends EventTarget {
this.dispatchEvent(event)
}
resize() {
const videoContentSize = this.getRenderedVideoContentSize(this.videoElement)
resize(containerWidth: number, containerHeight: number) {
const videoContentSize = this.getRenderedVideoContentSize(this.videoElement, containerWidth, containerHeight)
this._boxSize = { width: videoContentSize?.displayedWidth || 0, height: videoContentSize?.displayedHeight || 0 }
if (this.canvas) {
this.canvas.width = this._boxSize.width
@@ -367,10 +372,7 @@ export class VideoCoreAnime4KManager extends EventTarget {
}
}
private getRenderedVideoContentSize(video: HTMLVideoElement) {
const containerWidth = video.clientWidth
const containerHeight = video.clientHeight
private getRenderedVideoContentSize(video: HTMLVideoElement, containerWidth: number, containerHeight: number) {
const videoWidth = video.videoWidth
const videoHeight = video.videoHeight
@@ -381,28 +383,12 @@ export class VideoCoreAnime4KManager extends EventTarget {
let displayedWidth, displayedHeight
const objectFit = getComputedStyle(video).objectFit || "fill"
if (objectFit === "cover") {
if (videoRatio > 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()

View File

@@ -49,7 +49,7 @@ export const VideoCoreAnime4K = () => {
// Handle option changes
React.useLayoutEffect(() => {
if (video && manager) {
manager.resize()
manager.resize(realVideoSize.width, realVideoSize.height)
}
}, [realVideoSize])

View File

@@ -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}

View File

@@ -255,7 +255,7 @@ export function VideoCoreInSight() {
<div
data-vc-element="in-sight-character-image"
className={cn(
"w-32 pointer-events-none aspect-[2/3] overflow-hidden rounded-3xl relative shadow-lg bg-gray-900 border border-gray-900/20 transition-all duration-300",
"w-32 pointer-events-none aspect-[2/3] overflow-hidden rounded-3xl relative shadow-lg bg-gray-900 border border-gray-900/20 transition-[transform,opacity,border-radius] duration-300",
"scale-90 opacity-90 group-hover/in-sight-character:scale-110 group-hover/in-sight-character:opacity-100 ease-in-out group-hover/in-sight-character:rounded-3xl origin-bottom z-0 group-hover/in-sight-character:z-10",
)}
>

View File

@@ -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

View File

@@ -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 }

View File

@@ -599,7 +599,7 @@ export function VideoCorePreferencesModal({ isWebPlayer }: { isWebPlayer: boolea
<TextInput
value={editedSubsBlacklist}
onValueChange={setEditedSubsBlacklist}
placeholder="e.g. sign & songs"
placeholder="e.g. signs & songs,signs/songs"
onKeyDown={(e) => 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."

View File

@@ -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.

View File

@@ -25,7 +25,7 @@ export function VideoCoreTopSection(props: { children?: React.ReactNode, inline?
<div
data-vc-element="control-bar-top-section"
className={cn(
"absolute left-0 w-full py-4 px-5 duration-200 transition-all opacity-0 z-[999] transform-gpu",
"absolute left-0 w-full py-4 px-5 duration-200 transition-[opacity,transform] opacity-0 z-[999] transform-gpu",
(__isDesktop__ && ((inline && fullscreen) || !inline)) ? "top-8" : "top-0",
showTopSection && "opacity-100",
isMiniPlayer && "top-0",
@@ -41,7 +41,7 @@ export function VideoCoreTopSection(props: { children?: React.ReactNode, inline?
data-vc-element="control-bar-top-gradient"
className={cn(
"pointer-events-none transform-gpu",
"absolute top-0 left-0 right-0 w-full z-[5] transition-all duration-300 opacity-0",
"absolute top-0 left-0 right-0 w-full z-[5] transition-opacity duration-300 opacity-0",
"bg-gradient-to-b from-black/60 to-transparent",
"h-20",
(isMiniPlayer && paused) && "opacity-100",

View File

@@ -140,7 +140,7 @@ import { FiMinimize2 } from "react-icons/fi"
import { ImSpinner2 } from "react-icons/im"
import { PiSpinnerDuotone } from "react-icons/pi"
import { RemoveScrollBar } from "react-remove-scroll-bar"
import { useMeasure, useUnmount, useUpdateEffect, useWindowSize } from "react-use"
import { useUnmount, useUpdateEffect, useWindowSize } from "react-use"
const log = logger("VIDEO CORE")
@@ -754,14 +754,33 @@ export function VideoCore(props: VideoCoreProps) {
dispatchTerminatedEvent()
}
// Measure video element size
const [measureRef, { width, height }] = useMeasure<HTMLVideoElement>()
// measure video element size using a throttled ResizeObserver
const videoResizeObserverRef = React.useRef<ResizeObserver | null>(null)
const videoResizeRafRef = React.useRef<number | null>(null)
const videoResizeTargetRef = React.useRef<HTMLVideoElement | null>(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)
}

View File

@@ -71,25 +71,88 @@ export function useVideoCoreBindings(videoRef: React.MutableRefObject<HTMLVideoE
const setEnded = useSetAtom(vc_ended)
const setPaused = useSetAtom(vc_paused)
const prevRef = React.useRef({
videoWidth: 0,
videoHeight: 0,
duration: 0,
currentTime: 0,
playbackRate: 0,
readyState: 0,
buffering: false,
isMuted: false,
volume: 0,
ended: false,
paused: true,
bufferedLength: 0,
})
useEffect(() => {
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"]

View File

@@ -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 }

View File

@@ -311,10 +311,20 @@ export function ServerSettings(props: ServerSettingsProps) {
label={__isElectronDesktop__ ? "Do not fetch update notes" : "Do not check for updates"}
help={__isElectronDesktop__ ? (<span className="flex gap-2 items-center">
<LuCircleAlert className="size-4 text-[--blue]" />
<span>If enabled, new releases won't be displayed. Seanime Denshi will still auto-update in the background.</span>
<span>If enabled, new releases won't be displayed. Seanime Denshi may still auto-update in the background.</span>
</span>) : "If enabled, Seanime will not check for new releases."}
moreHelp={__isElectronDesktop__ ? "You cannot disable auto-updates for Seanime Denshi." : undefined}
/>
<Field.Select
label="Update Channel"
name="updateChannel"
help={__isElectronDesktop__ ? "Also applies to Seanime Denshi auto-updates." : undefined}
options={[
{ label: "GitHub", value: "github" },
{ label: "Seanime", value: "seanime" },
{ label: "Seanime (Nightly)", value: "seanime_nightly" },
]}
/>
</SettingsCard>
{/*<Accordion*/}

View File

@@ -1,9 +1,13 @@
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { useOpenInExplorer } from "@/api/hooks/explorer.hooks"
import { useAnimeListTorrentProviderExtensions } from "@/api/hooks/extensions.hooks"
import { useCheckForUpdates } from "@/api/hooks/releases.hooks"
import { useSaveSettings } from "@/api/hooks/settings.hooks"
import { useGetTorrentstreamSettings } from "@/api/hooks/torrentstream.hooks"
import { electronUpdateModalOpenAtom } from "@/app/(main)/_electron/electron-update-modal"
import { CustomLibraryBanner } from "@/app/(main)/_features/anime-library/_containers/custom-library-banner"
import { __issueReport_overlayOpenAtom } from "@/app/(main)/_features/issue-report/issue-report"
import { updateModalOpenAtom as webUpdateModalOpenAtom } from "@/app/(main)/_features/update/update-modal"
import { useServerDisabledFeatures, useServerStatus, useSetServerStatus } from "@/app/(main)/_hooks/use-server-status"
import { ExternalPlayerLinkSettings, MediaplayerSettings } from "@/app/(main)/settings/_components/mediaplayer-settings"
import { PlaybackSettings } from "@/app/(main)/settings/_components/playback-settings"
@@ -30,6 +34,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useRouter, useSearchParams } from "@/lib/navigation"
import { DEFAULT_TORRENT_CLIENT, DEFAULT_TORRENT_PROVIDER, settingsSchema, TORRENT_PROVIDER } from "@/lib/server/settings"
import { __isElectronDesktop__ } from "@/types/constants"
import { useQueryClient } from "@tanstack/react-query"
import { useSetAtom } from "jotai"
import { useAtom } from "jotai/react"
import capitalize from "lodash/capitalize"
@@ -52,11 +57,13 @@ import {
LuTabletSmartphone,
LuWandSparkles,
} from "react-icons/lu"
import { LuRefreshCw } from "react-icons/lu"
import { MdOutlineConnectWithoutContact, MdOutlineDownloading, MdOutlinePalette } from "react-icons/md"
import { RiFolderDownloadFill } from "react-icons/ri"
import { SiBittorrent, SiQbittorrent, SiTransmission } from "react-icons/si"
import { TbDatabaseExclamation } from "react-icons/tb"
import { VscDebugAlt } from "react-icons/vsc"
import { toast } from "sonner"
import { SettingsCard, SettingsNavCard, SettingsPageHeader } from "./_components/settings-card"
import { DenshiSettings } from "./_containers/denshi-settings"
import { DiscordRichPresenceSettings } from "./_containers/discord-rich-presence-settings"
@@ -73,6 +80,7 @@ export default function Page() {
const { isFeatureDisabled, showFeatureWarning } = useServerDisabledFeatures()
const setServerStatus = useSetServerStatus()
const router = useRouter()
const queryClient = useQueryClient()
const searchParams = useSearchParams()
@@ -87,6 +95,10 @@ export default function Page() {
const { mutate: openInExplorer, isPending: isOpening } = useOpenInExplorer()
const { mutate: checkForUpdates, isPending: isCheckingForUpdates } = useCheckForUpdates()
const setWebUpdateModalOpen = useSetAtom(webUpdateModalOpenAtom)
const setElectronUpdateModalOpen = useSetAtom(electronUpdateModalOpenAtom)
React.useEffect(() => {
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
</Button>
<Button
size="sm"
intent="gray-outline"
onClick={() => {
checkForUpdates(undefined, {
onSuccess: (data) => {
if (data?.release) {
queryClient.setQueryData([API_ENDPOINTS.RELEASES.GetLatestUpdate.key], data)
if (__isElectronDesktop__) {
// Also trigger Electron update
if (window.electron) {
window.electron.checkForUpdates().catch(() => { })
}
setElectronUpdateModalOpen(true)
} else {
setWebUpdateModalOpen(true)
}
} else {
toast.success("You are running the latest version")
}
},
})
}}
loading={isCheckingForUpdates}
leftIcon={<LuRefreshCw className="transition-transform duration-200 group-hover:rotate-180" />}
className="transition-all duration-200 hover:scale-105 hover:shadow-md group"
data-check-for-updates-button
>
Check for updates
</Button>
</div>
<ServerSettings isPending={isPending} />

View File

@@ -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<typeof gettingStartedSchema>):
useFallbackMetadataProvider: false,
scannerUseLegacyMatching: false,
scannerConfig: "",
updateChannel: "github",
},
nakama: {
enabled: false,

View File

@@ -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(
<ClientProviders>
<RouterProvider router={router} />

View File

@@ -80,5 +80,6 @@ declare global {
minimizeToTray: boolean;
openInBackground: boolean;
openAtLaunch: boolean;
updateChannel?: string;
}
}