Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"dev": "vite",
"build": "vite build && electron-builder --linux --mac --win",
"build:mac": "vite build && electron-builder --mac",
"preview": "vite preview"
"preview": "vite preview",
"postinstall": "patch-package"
},
"build": {
"asar": true,
Expand Down Expand Up @@ -103,6 +104,7 @@
"electron-builder": "^24.13.3",
"eslint": "^9.9.1",
"naive-ui": "^2.41.0",
"patch-package": "^8.0.1",
"prettier": "^3.3.3",
"sass": "^1.78.0",
"typescript": "^5.5.3",
Expand Down
72 changes: 72 additions & 0 deletions patches/guacamole-common-js-jumpserver+1.1.0-c.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
diff --git a/node_modules/guacamole-common-js-jumpserver/dist/guacamole-common.js b/node_modules/guacamole-common-js-jumpserver/dist/guacamole-common.js
index 542c77b..4d9e4bf 100644
--- a/node_modules/guacamole-common-js-jumpserver/dist/guacamole-common.js
+++ b/node_modules/guacamole-common-js-jumpserver/dist/guacamole-common.js
@@ -10679,6 +10679,14 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) {
*/
var seekTimeout = null;

+ /**
+ * 播放倍速,1 为正常速度。
+ *
+ * @private
+ * @type {Number}
+ */
+ var playbackSpeed = 1;
+
// Start playback client connected
playbackClient.connect();

@@ -10931,8 +10939,8 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) {
var next = frames[currentFrame + 1];

// Calculate the real timestamp corresponding to when the next
- // frame begins
- var nextRealTimestamp = next.timestamp - startVideoTimestamp + startRealTimestamp;
+ // frame begins (scaled by playback speed)
+ var nextRealTimestamp = (next.timestamp - startVideoTimestamp) / playbackSpeed + startRealTimestamp;

// Calculate the relative delay between the current time and
// the next frame start
@@ -11167,6 +11175,41 @@ Guacamole.SessionRecording = function SessionRecording(tunnel) {

};

+ /**
+ * 设置播放倍速。播放过程中切换时会重新校准时间轴,避免进度跳变。
+ *
+ * @param {Number} rate
+ * 倍速值,有效范围 0.25 ~ 4。
+ */
+ this.setSpeed = function setSpeed(rate) {
+
+ var newRate = Math.max(0.25, Math.min(rate, 4));
+ if (newRate === playbackSpeed)
+ return;
+
+ var wasPlaying = recording.isPlaying();
+ if (wasPlaying) {
+ abortSeek();
+ startVideoTimestamp = frames[currentFrame].timestamp;
+ startRealTimestamp = new Date().getTime();
+ playbackSpeed = newRate;
+ continuePlayback();
+ }
+ else
+ playbackSpeed = newRate;
+
+ };
+
+ /**
+ * 获取当前播放倍速。
+ *
+ * @returns {Number}
+ * 当前倍速值。
+ */
+ this.getSpeed = function getSpeed() {
+ return playbackSpeed;
+ };
+
};

/**
3 changes: 2 additions & 1 deletion src/lang/modules/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ export default {
emptyCommand: 'The command list is empty',
videoInformation: 'Video Information: ',
toggleLangSuccess: 'Language changed',
scalingRatio: 'Scaling Ratio'
scalingRatio: 'Scaling Ratio',
playbackSpeed: 'Playback Speed',
};
1 change: 1 addition & 0 deletions src/lang/modules/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ export default {
videoInformation: '视频信息:',
toggleLangSuccess: '语言已更改',
scalingRatio: '缩放比',
playbackSpeed: '播放速率',
};
173 changes: 165 additions & 8 deletions src/views/asciinemaPlayer/index.vue
Original file line number Diff line number Diff line change
@@ -1,40 +1,197 @@
<template>
<div class="terminal-root">
<div id="terminal"></div>
<div class="cast-player-root">
<div class="player-area" ref="playerAreaRef">
<div id="terminal" ref="terminalRef"></div>
</div>

<div class="controls">
<div class="controls-left">
<n-text class="time-text">{{ t('playbackSpeed') }}</n-text>
</div>

<div class="controls-right">
<n-select
v-model:value="playbackSpeed"
:options="speedOptions"
size="small"
class="speed-select"
@update:value="handleSpeedChange"
/>
</div>
</div>
</div>
</template>

<script setup lang="ts">
// @ts-ignore
import * as AsciinemaPlayer from '@cyolosecurity/asciinema-player';
import { useRoute } from 'vue-router';
import { computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';

/** Asciinema 播放器实例类型(库未导出完整类型,按需声明常用方法) */
interface IAsciinemaPlayerInstance {
dispose: () => void;
getCurrentTime: () => number;
play: () => void;
pause: () => void;
addEventListener: (name: string, callback: () => void) => void;
}

const route = useRoute();
const { t } = useI18n();

const castUrl = computed(() => route.params?.castUrl as string);
const terminalRef = ref<HTMLElement | null>(null);
const playerAreaRef = ref<HTMLElement | null>(null);

onMounted(() => {
AsciinemaPlayer.create(castUrl.value, document.getElementById('terminal')!, {
/** 当前播放倍速,默认 1x */
const playbackSpeed = ref(1);
/** 是否正在播放,用于切换倍速后恢复播放状态 */
const isPlaying = ref(false);

/** 倍速选项,与 Gua / MP4 播放器保持一致 */
const speedOptions = [
{ label: '0.75x', value: 0.75 },
{ label: '1x', value: 1 },
{ label: '1.5x', value: 1.5 },
{ label: '2x', value: 2 }
];

let playerInstance: IAsciinemaPlayerInstance | null = null;

/**
* @description 创建或重建 Asciinema 播放器
* @param url cast 文件 ObjectURL
* @param speed 播放倍速
* @param startAt 起始时间(秒)
* @param autoPlay 是否自动播放
*
* 说明:Asciinema 仅在 create 时接受 speed 参数,运行时切换倍速需销毁并重建实例。
*/
const mountPlayer = (
url: string,
speed: number,
startAt = 0,
autoPlay = true
) => {
const container = terminalRef.value;
if (!container || !url) return;

// 销毁旧实例,避免重复挂载
if (playerInstance) {
playerInstance.dispose();
playerInstance = null;
}

container.innerHTML = '';

playerInstance = AsciinemaPlayer.create(url, container, {
fit: 'both',
preload: true,
autoplay: true
autoPlay,
speed,
startAt,
controls: 'auto'
}) as IAsciinemaPlayerInstance;

// 监听播放状态,便于倍速切换后恢复
playerInstance.addEventListener('play', () => {
isPlaying.value = true;
});
playerInstance.addEventListener('pause', () => {
isPlaying.value = false;
});
};

/**
* @description 切换播放倍速
* @param speed 新的倍速值
*/
const handleSpeedChange = (speed: number) => {
if (!playerInstance) return;

const currentTime = playerInstance.getCurrentTime();
const shouldPlay = isPlaying.value;

playerInstance.pause();
mountPlayer(castUrl.value, speed, currentTime, shouldPlay);
};

watch(
() => castUrl.value,
url => {
playbackSpeed.value = 1;
isPlaying.value = false;
mountPlayer(url, 1, 0, true);
}
);

onMounted(() => {
mountPlayer(castUrl.value, playbackSpeed.value, 0, true);
});

onBeforeUnmount(() => {
playerInstance?.dispose();
playerInstance = null;
});
</script>

<style scoped lang="scss">
.terminal-root {
width: 100%;
.cast-player-root {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
width: 100%;
}

.player-area {
flex: 1 1 auto;
min-height: 0;
width: 100%;
overflow: hidden;
}

#terminal {
width: 100%;
height: 100%;
}

.controls {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
width: 100%;
box-sizing: border-box;
}

.controls-left {
flex: 0 0 auto;
}

.time-text {
font-size: 14px;
}

.controls-right {
flex: 0 0 auto;
display: flex;
align-items: center;
}

.speed-select {
width: 88px;
}

:deep(.ap-wrapper) {
width: 100%;
height: 100%;
}

:deep(.ap-player) {
width: 100% !important;
height: 100% !important;
Expand Down
35 changes: 34 additions & 1 deletion src/views/guaPlayer/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@
</div>

<div class="controls-right">
<n-select
v-model:value="playbackSpeed"
:options="speedOptions"
size="small"
class="speed-select"
:disabled="isProcessing"
@update:value="handleSpeedChange"
/>
<n-tag round size="small" :bordered="false" type="success">
{{ t('scalingRatio') }}: {{ Math.round(scale * 100) }}%
</n-tag>
Expand Down Expand Up @@ -73,6 +81,16 @@ const isPlaying = ref(false);
const isProcessing = ref(false);
const loadingBuffer = ref(false);
const fileDataEndCalled = ref(false);
/** 当前播放倍速,默认 1x */
const playbackSpeed = ref(1);

/** 倍速选项,与 MP4 播放器保持一致 */
const speedOptions = [
{ label: '0.75x', value: 0.75 },
{ label: '1x', value: 1 },
{ label: '1.5x', value: 1.5 },
{ label: '2x', value: 2 }
];

const recomputeScale = () => {
if (!display || !display.getDefaultLayer) return;
Expand Down Expand Up @@ -201,6 +219,15 @@ const initRecordingEvent = (record: any) => {
setTimeout(slowRecompute, 600);
};

/**
* @description 切换播放倍速
* @param speed 倍速值
*/
const handleSpeedChange = (speed: number) => {
if (!recording?.setSpeed) return;
recording.setSpeed(speed);
};

/**
* @description 播放/暂停
*/
Expand Down Expand Up @@ -464,6 +491,7 @@ onUnmounted(() => {
chunks.value = '';
currentPercent.value = 0;
currentPosition.value = '00:00';
playbackSpeed.value = 1;
isPlaying.value = false;
});
</script>
Expand Down Expand Up @@ -535,7 +563,12 @@ onUnmounted(() => {
display: flex;
align-items: center;
justify-content: flex-end;
width: 200px;
gap: 8px;
width: 280px;
}

.speed-select {
width: 88px;
}

.scale-text {
Expand Down
Loading