mirror of
https://github.com/5rahim/seanime
synced 2026-04-18 22:24:55 +02:00
feat: fixed videocore perf
feat: update channels
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -735,6 +735,7 @@ export type DownloadTorrentFile_Variables = {
|
||||
download_urls: Array<string>
|
||||
destination: string
|
||||
media?: AL_BaseAnime
|
||||
clientId: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3831,6 +3831,10 @@ export type Models_LibrarySettings = {
|
||||
useFallbackMetadataProvider: boolean
|
||||
scannerUseLegacyMatching: boolean
|
||||
scannerConfig: string
|
||||
/**
|
||||
* "github", "seanime", "seanime_nightly"
|
||||
*/
|
||||
updateChannel: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 () => {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -49,7 +49,7 @@ export const VideoCoreAnime4K = () => {
|
||||
// Handle option changes
|
||||
React.useLayoutEffect(() => {
|
||||
if (video && manager) {
|
||||
manager.resize()
|
||||
manager.resize(realVideoSize.width, realVideoSize.height)
|
||||
}
|
||||
}, [realVideoSize])
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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*/}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
1
seanime-web/src/types/index.d.ts
vendored
1
seanime-web/src/types/index.d.ts
vendored
@@ -80,5 +80,6 @@ declare global {
|
||||
minimizeToTray: boolean;
|
||||
openInBackground: boolean;
|
||||
openAtLaunch: boolean;
|
||||
updateChannel?: string;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user